Skip to content

Commit 8cd7c40

Browse files
committed
ADR-0005 Phase 1: registry-driven overlay opt-in + partial index
1 parent 85f60ac commit 8cd7c40

13 files changed

Lines changed: 515 additions & 49 deletions

File tree

.github/copilot-instructions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
- Philosophy: "Data as Code", Idempotency, and Immutable Infrastructure are the defaults.
2929
6. **Long-Term Architecture:** Do NOT use simplified or temporary workarounds. Always adopt sustainable, well-architected solutions.
3030
7. **Short Object Names Are Canonical:** Always write **short** object names (e.g. `account`, `task`) in user code, examples, queries, REST URLs, formulas, hooks, and lookups. **Never** type FQN (`{namespace}__name`, e.g. `crm__account`) in user-facing code, docs, or AI-generated output. **Never** set `tableName` on an object schema — the field has been removed; the physical table name is always derived from the short name. FQN is an internal-only mechanic used by the registry for cross-package disambiguation, marketplace publishing, and customization `baseName` references.
31+
8. **One Zod Source per Metadata Type:** Each metadata type (`view`, `dashboard`, `flow`, `agent`, `tool`, `object`, …) has exactly **one** Zod schema in `packages/spec/src/{domain}/`. Do **not** re-declare the same shape as a `*.object.ts` projection table — that pattern is removed (see ADR-0005). Runtime org overlay opt-in lives in **one** place: the `allowOrgOverride` flag on the type's entry in `DEFAULT_METADATA_TYPE_REGISTRY` (`packages/spec/src/kernel/metadata-plugin.zod.ts`); never maintain a parallel whitelist. The overlay validator (`resolveOverlaySchema()` in `packages/objectql/src/protocol.ts`) and Studio editing forms both bind to the canonical Zod schema.
3132

3233
---
3334

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ Mission: Build the "Post-SaaS Operating System" — an open-core, local-first ec
6464
- Philosophy: "Data as Code", Idempotency, and Immutable Infrastructure are the defaults.
6565
6. **Long-Term Architecture:** Do NOT use simplified or temporary workarounds. Always adopt sustainable, well-architected solutions.
6666
7. **Object Name = Table Name:** The object `name` is the canonical identifier used everywhere — API paths, ObjectQL queries, REST URLs, SDK calls, and the physical database table. There is no namespace prefix mechanism; if a module needs a prefix, it is embedded directly in the `name` (e.g. `name: 'sys_user'`, `name: 'ai_conversations'`). **Never** set `namespace` on an object schema — the field is deprecated and ignored. **Never** set `tableName` — the physical table name always equals the object `name`.
67+
8. **One Zod Source per Metadata Type:** Each metadata type (`view`, `dashboard`, `flow`, `agent`, `tool`, `object`, …) has exactly **one** Zod schema in `packages/spec/src/{domain}/`. Do **not** re-declare the same shape as a `*.object.ts` projection table — that pattern is removed (see ADR-0005). Runtime org overlay opt-in lives in **one** place: the `allowOrgOverride` flag on the type's entry in `DEFAULT_METADATA_TYPE_REGISTRY` (`packages/spec/src/kernel/metadata-plugin.zod.ts`); never maintain a parallel whitelist. The overlay validator (`resolveOverlaySchema()` in `packages/objectql/src/protocol.ts`) and Studio editing forms both bind to the canonical Zod schema.
6768

6869
---
6970

CONTRIBUTING.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,20 @@ Adding or modifying protocol definitions in `packages/spec/src/`:
117117
4. **Generate Schemas** - Run `pnpm build`
118118
5. **Create Documentation** - Add MDX in `content/docs/references/`
119119

120+
> **Single Source of Truth — One Zod schema per metadata type.**
121+
> Each metadata type (`view`, `dashboard`, `flow`, `agent`, `tool`, `object`, …)
122+
> has exactly **one** Zod schema under `packages/spec/src/{domain}/`. Do
123+
> **not** re-declare the same shape as a `*.object.ts` projection table —
124+
> that pattern was removed in ADR-0005 (see `docs/adr/0005-…`). Studio
125+
> editing forms and the overlay validator (`resolveOverlaySchema()` in
126+
> `packages/objectql/src/protocol.ts`) both bind to that one schema.
127+
>
128+
> **Runtime opt-in for org overlays** lives in exactly one place: the
129+
> `allowOrgOverride` boolean on the type's entry in
130+
> `DEFAULT_METADATA_TYPE_REGISTRY`
131+
> (`packages/spec/src/kernel/metadata-plugin.zod.ts`). Do not maintain a
132+
> parallel whitelist in runtime code.
133+
120134
Example:
121135
```typescript
122136
/**

apps/console/src/loadLanguage.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,15 @@ function transformSpecTranslations(data: Record<string, unknown>): Record<string
8383
const obj: Record<string, unknown> = { label: objData.label };
8484
if (objData.pluralLabel) obj.pluralLabel = objData.pluralLabel;
8585
if (objData.description) obj.description = objData.description;
86+
// Preserve spec-defined nested scopes that the i18n resolvers consume
87+
// directly (objects.<n>._views.<v>.label, etc.) — these are NOT
88+
// flattened to top-level namespaces.
89+
if (objData._views) obj._views = objData._views;
90+
if (objData._actions) obj._actions = objData._actions;
91+
if (objData._sections) obj._sections = objData._sections;
92+
if (objData._notifications) obj._notifications = objData._notifications;
93+
if (objData._errors) obj._errors = objData._errors;
94+
if (objData._options) obj._options = objData._options;
8695
objects[objName] = obj;
8796

8897
// Flatten fields: objects.X.fields.Y.label → fields.X.Y = string

content/docs/references/kernel/metadata-plugin.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ const result = MetadataBulkRegisterRequest.parse(data);
256256
| **description** | `string` | optional | Description of the metadata type |
257257
| **filePatterns** | `string[]` || Glob patterns to discover files of this type |
258258
| **supportsOverlay** | `boolean` || Whether overlay customization is supported |
259+
| **allowOrgOverride** | `boolean` || Allow per-org overlay writes via runtime metadata API |
259260
| **allowRuntimeCreate** | `boolean` || Allow runtime creation via API |
260261
| **supportsVersioning** | `boolean` || Whether version history is tracked |
261262
| **loadOrder** | `integer` || Loading priority (lower = earlier) |

content/docs/references/ui/view.mdx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ Migrated to shared/http.zod.ts. Re-exported here for backward compatibility.
1616
## TypeScript Usage
1717

1818
```typescript
19-
import { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnSummary, GalleryConfig, GanttConfig, GroupingConfig, GroupingField, KanbanConfig, ListColumn, NavigationConfig, NavigationMode, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, UserActionsConfig, ViewData, ViewFilterRule, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui';
20-
import type { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnSummary, GalleryConfig, GanttConfig, GroupingConfig, GroupingField, KanbanConfig, ListColumn, NavigationConfig, NavigationMode, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, UserActionsConfig, ViewData, ViewFilterRule, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui';
19+
import { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnSummary, GalleryConfig, GanttConfig, GroupingConfig, GroupingField, KanbanConfig, ListChartConfig, ListColumn, NavigationConfig, NavigationMode, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, UserActionsConfig, ViewData, ViewFilterRule, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui';
20+
import type { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnSummary, GalleryConfig, GanttConfig, GroupingConfig, GroupingField, KanbanConfig, ListChartConfig, ListColumn, NavigationConfig, NavigationMode, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, UserActionsConfig, ViewData, ViewFilterRule, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui';
2121

2222
// Validate data
2323
const result = AddRecordConfig.parse(data);
@@ -159,6 +159,23 @@ Record grouping configuration
159159
| **columns** | `string[]` || Fields to show on cards |
160160

161161

162+
---
163+
164+
## ListChartConfig
165+
166+
List chart view configuration
167+
168+
### Properties
169+
170+
| Property | Type | Required | Description |
171+
| :--- | :--- | :--- | :--- |
172+
| **chartType** | `Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>` || Chart visualisation type |
173+
| **xAxisField** | `string` || Field used as the X axis / category dimension |
174+
| **yAxisFields** | `string[]` || Field(s) used as the Y axis / measures |
175+
| **aggregation** | `Enum<'sum' \| 'avg' \| 'count' \| 'min' \| 'max'>` | optional | Aggregation function applied to Y axis fields |
176+
| **groupByField** | `string` | optional | Optional field used to split / stack the chart |
177+
178+
162179
---
163180

164181
## ListColumn

docs/adr/0005-metadata-customization-overlay.md

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,3 +271,195 @@ Tests added in `packages/objectql/src/protocol-meta.test.ts`
271271
(`describe('spec validation', …)`): valid view/dashboard accepted, invalid
272272
shapes return 422 with `issues`, unknown extras preserved, unregistered
273273
types fall through, plural type strings normalize correctly.
274+
275+
---
276+
277+
## Addendum — 2026-05-16 (d): registry-driven opt-in + overlay-uniqueness index
278+
279+
### Registry-driven opt-in (was: hard-coded whitelist)
280+
281+
`packages/objectql/src/protocol.ts` previously gated `PUT/DELETE
282+
/api/v1/meta/:type/:name` against a **hard-coded** `Set` of allowed types
283+
(`OVERLAY_ALLOWED_TYPES = new Set(['view', 'dashboard'])`). Any new metadata
284+
type that wanted to participate in the overlay system had to find and edit
285+
that constant — invisible coupling between the spec and the runtime.
286+
287+
That whitelist is now derived from the **metadata type registry** (the
288+
single source of truth defined in
289+
`packages/spec/src/kernel/metadata-plugin.zod.ts`):
290+
291+
- Added a new boolean field `allowOrgOverride: z.boolean().default(false)` to
292+
`MetadataTypeRegistryEntrySchema`. TSDoc spells out the distinction:
293+
- `supportsOverlay` is **capability** (the loader can merge overlays).
294+
- `allowOrgOverride` is **runtime permission** (per-tenant writes via the
295+
REST overlay endpoint are accepted).
296+
- In `DEFAULT_METADATA_TYPE_REGISTRY`, only `view` and `dashboard` opt in
297+
(`allowOrgOverride: true`). Every other entry sets it explicitly to `false`
298+
to keep the table self-documenting.
299+
- `protocol.ts` builds `OVERLAY_ALLOWED_TYPES` at module-load time by walking
300+
the registry and including each opted-in singular type plus its plural form
301+
(via `SINGULAR_TO_PLURAL`). A new helper `isOverlayAllowed(type)`
302+
normalizes plural inputs back to singular for safety, since REST callers
303+
may use either form.
304+
- Save/delete reject unsupported types with **HTTP 403 `not_overridable`**
305+
(was `customization_not_allowed` / 400). The new code is more accurate
306+
semantically — the type is definitionally not overridable, not just
307+
unsupported in the current build.
308+
309+
To extend the overlay surface to a new type (e.g. `flow`), the only edit
310+
required is in `metadata-plugin.zod.ts`:
311+
312+
```ts
313+
{ type: 'flow', …, allowOrgOverride: true, … }
314+
```
315+
316+
…plus adding the type's Zod schema to `resolveOverlaySchema()` in
317+
`protocol.ts` so the new payloads are validated. No `Set` editing.
318+
319+
### Overlay-uniqueness index (`schema-index`)
320+
321+
The `sys_metadata` schema previously declared a UNIQUE index on
322+
`(type, name, project_id)`. Pre-overlay this was correct; post-overlay it
323+
would forbid two organizations from each having their own customization of
324+
the same `(type, name)` within a project — the central use case ADR-0005
325+
exists to support.
326+
327+
Replaced with a partial UNIQUE index that scopes uniqueness by
328+
organization and lifecycle state:
329+
330+
```text
331+
indexes: [
332+
{
333+
name: 'idx_sys_metadata_overlay_active',
334+
fields: ['type', 'name', 'organization_id', 'project_id', 'scope'],
335+
unique: true,
336+
partial: "state = 'active'",
337+
},
338+
339+
]
340+
```
341+
342+
Drivers ignore `indexes` declarations on synced tables today, so a new
343+
idempotent migration is provided and run automatically by
344+
`DatabaseLoader.ensureSchema()`:
345+
346+
- `addSysMetadataOverlayIndex(driver)` — exported from
347+
`@objectstack/metadata/migrations`.
348+
- Issues `CREATE UNIQUE INDEX IF NOT EXISTS … WHERE state = 'active'`.
349+
- Falls back to a non-unique composite index on engines that don't support
350+
partial indexes (MySQL); application code in `saveMetaItem` provides
351+
upsert semantics so duplicates are still prevented.
352+
- Failures are non-fatal — the migration logs and returns; the app boots.
353+
354+
### Design principle (codified)
355+
356+
> **One Zod source per metadata type.**
357+
>
358+
> For each metadata type (view, dashboard, flow, agent, tool, …):
359+
>
360+
> 1. The **shape** lives in exactly one Zod schema under
361+
> `@objectstack/spec/{domain}/*.zod.ts`.
362+
> 2. The **opt-in for runtime org customization** lives in exactly one
363+
> place: the `allowOrgOverride` boolean on its
364+
> `DEFAULT_METADATA_TYPE_REGISTRY` entry.
365+
> 3. The **overlay validator** lives in exactly one place:
366+
> `resolveOverlaySchema()` in `packages/objectql/src/protocol.ts`.
367+
>
368+
> Do **not** re-declare the same shape as a `*.object.ts` (the
369+
> projection-table pattern is removed; see Addendum 2026-05-16 (b)).
370+
> Do **not** maintain a parallel whitelist of "types that can be edited at
371+
> runtime" outside the registry.
372+
>
373+
> Forms in Studio that edit metadata should ultimately be derived from the
374+
> Zod schema (Phase 6 — `p6-form-render-from-zod`); until then, hand-rolled
375+
> dialogs MUST be kept in lock-step with the canonical schema (the
376+
> 2026-05-16 (d) view-types fix in §14 of the working plan is what happens
377+
> when they drift).
378+
379+
---
380+
381+
## Addendum — 2026-05-16 (e): artifact source & refresh semantics
382+
383+
ADR-0005 says the **artifact** (`dist/objectstack.json`) is the source of
384+
truth for built-in metadata, while `sys_metadata` carries per-org overlays.
385+
The artifact has multiple plausible delivery channels in production. This
386+
addendum nails down what each implies for overlay validity, cache
387+
invalidation, and upgrade behaviour. The runtime defaults to the local
388+
file source today; the others are forward-compatible plans.
389+
390+
### Artifact source variants
391+
392+
| Source | Typical environment | How runtime fetches | Mutation event |
393+
|---|---|---|---|
394+
| **Local file** (default) | Dev, single-host servers | `fs.readFile(dist/objectstack.json)` at boot | Process restart / SIGHUP |
395+
| **HTTP fetch** | Multi-tenant cloud, control-plane–driven rollouts | Boot-time GET against control plane (`GET /api/v1/control/artifact/:project/:version`); ETag cached on disk | Webhook from control plane → SIGHUP / hot-reload |
396+
| **OCI image layer** | Containerised deploys, GitOps pipelines | Image tag pin → image pull → file mounts at known path → boot reads file | Pod rollout (Kubernetes / Nomad) |
397+
398+
All three converge on the same in-memory shape: the parsed artifact is
399+
loaded into `SchemaRegistry`. The differences are purely about **when** a
400+
new shape becomes visible and **how** the runtime is told to re-load.
401+
402+
### Refresh model (current vs. future)
403+
404+
- **Today (Phase 1–6):** Boot-time only. Re-reading the artifact requires
405+
process restart. Overlay rows in `sys_metadata` are independent and
406+
outlive restarts; they are merged on every read.
407+
- **Future (post-ADR-0005):** A `metadataRegistry.reload()` API that:
408+
1. Re-reads the artifact (file / HTTP / OCI).
409+
2. Diffs the new artifact against the in-memory baseline.
410+
3. Drops the metadata cache (and the `DatabaseLoader` LRU) for any
411+
`(type, name)` whose baseline changed.
412+
4. Re-validates each affected overlay row against the **new** baseline
413+
schema (see "Customization validation" below).
414+
415+
### Customization validation against artifact upgrades
416+
417+
When the artifact upgrades (a new package version of the underlying view /
418+
dashboard ships), an existing org overlay may reference fields the new
419+
schema no longer accepts (renamed columns, removed sub-configs, tightened
420+
enums). The runtime cannot silently merge a stale overlay — it must
421+
quarantine it and surface a remediation hook.
422+
423+
**Strategy:**
424+
425+
1. After artifact load, walk all overlay rows for affected `(type, name)`.
426+
2. Run them through the canonical Zod schema (the same
427+
`resolveOverlaySchema()` used at write time).
428+
3. On failure:
429+
- Set `state = 'conflict'` (existing column on `sys_metadata`).
430+
- Append a `sys_metadata_history` entry with `actor='system:upgrade'`,
431+
reason `artifact_schema_drift`, and the Zod issues array.
432+
- Exclude the row from the read-time merge → effective metadata falls
433+
back to the artifact baseline (graceful degradation, no broken UI).
434+
4. Studio surfaces a per-org "Conflicts" inbox listing the quarantined
435+
rows so an admin can re-edit and re-save (which clears `conflict`).
436+
437+
This bounded validation step is cheap (overlays are typically tens of
438+
rows per org per project) and runs only on artifact upgrade events, not
439+
on every request.
440+
441+
### Cache invalidation contract
442+
443+
| Cache | Owner | Invalidation trigger |
444+
|---|---|---|
445+
| `SchemaRegistry` (artifact data) | Kernel | Boot, explicit `reload()` |
446+
| `DatabaseLoader.LRUCache` (overlay rows) | `@objectstack/metadata` | Per-`(type, name, org)` on save / delete; full clear on `reload()` |
447+
| In-memory merged effective metadata | `protocol.ts` `getMetaItem` (no cache today) | N/A — re-merged on every call (Phase 3 deferred caching) |
448+
449+
The merged-effective layer is intentionally uncached today. When that
450+
becomes a hotspot, the cache key must be `(type, name, organization_id,
451+
artifactVersionHash)` so that an artifact bump naturally invalidates all
452+
entries derived from the prior baseline.
453+
454+
### Open questions (out of scope for this ADR)
455+
456+
- **Atomic artifact swap on running instances** — file source can be
457+
`mv` and re-read; HTTP/OCI sources need rendezvous semantics (drain
458+
in-flight requests before swap?). Defer to a deployment-runtime ADR.
459+
- **Per-tenant artifact pinning** — if tenants must upgrade independently,
460+
the registry needs a tenant-aware artifact resolver. Today's model
461+
assumes one artifact per ObjectOS instance.
462+
- **Cross-version overlay migration scripts** — when a field is renamed in
463+
a new artifact, an overlay that referenced the old name should ideally
464+
be auto-rewritten rather than quarantined. Requires a migration
465+
contract on the package author side; tracked separately.

packages/metadata/src/loaders/database-loader.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type { IDataDriver, IDataEngine } from '@objectstack/spec/contracts';
2424
import type { MetadataLoader } from './loader-interface.js';
2525
import { calculateChecksum } from '../utils/metadata-history-utils.js';
2626
import { LRUCache } from '../utils/lru-cache.js';
27+
import { addSysMetadataOverlayIndex } from '../migrations/add-sys-metadata-overlay-index.js';
2728

2829
/**
2930
* Cache configuration for `DatabaseLoader`.
@@ -255,6 +256,28 @@ export class DatabaseLoader implements MetadataLoader {
255256
// When using engine, schema sync is handled by ObjectQL startup
256257
if (this.engine) {
257258
this.schemaReady = true;
259+
// Best-effort: also ensure the overlay-uniqueness index.
260+
// The engine-managed driver may still benefit from a partial UNIQUE
261+
// INDEX (ADR-0005). Failures are swallowed by the migration itself.
262+
try {
263+
const engineAny = this.engine as any;
264+
let driver: IDataDriver | undefined =
265+
engineAny?.driver ?? engineAny?.getDriver?.();
266+
if (!driver && engineAny?.drivers instanceof Map) {
267+
for (const candidate of engineAny.drivers.values()) {
268+
const c = candidate as any;
269+
if (c && (typeof c.raw === 'function' || typeof c.execute === 'function')) {
270+
driver = candidate as IDataDriver;
271+
break;
272+
}
273+
}
274+
}
275+
if (driver) {
276+
await addSysMetadataOverlayIndex(driver);
277+
}
278+
} catch {
279+
// ignore — index is an optimization, not a correctness invariant
280+
}
258281
return;
259282
}
260283

@@ -264,6 +287,12 @@ export class DatabaseLoader implements MetadataLoader {
264287
name: this.tableName,
265288
});
266289
this.schemaReady = true;
290+
// Apply ADR-0005 partial UNIQUE INDEX (best-effort, idempotent)
291+
try {
292+
await addSysMetadataOverlayIndex(this.driver!);
293+
} catch {
294+
// ignore — index is optimization
295+
}
267296
} catch {
268297
// If syncSchema fails (e.g. table already exists), mark ready and continue
269298
this.schemaReady = true;

0 commit comments

Comments
 (0)