Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/remove-form-surfaced-dead-props-2377.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
"@objectstack/spec": minor
---

feat(spec)!: remove form-surfaced dead metadata props + correct 3 misclassified-live entries (#2377, ADR-0049)

The next enforce-or-remove slice of #2377. Versioned `minor` per the launch-window
policy (the fixed group makes a `major` promote the whole monorepo).

## Removed (dead, no runtime reader — verified in both framework and objectui)

- **field**: `columnName`, `index`, `referenceFilters`. This empties the field
dead-prop set. `columnName` also removed its now-moot **ADR-0062 D7** lint
(`validate-expressions.ts`), the dead `StorageNameMapping.resolveColumnName` /
`buildColumnMap` / `buildReverseColumnMap` helpers, and closes ADR-0062 R10 —
external physical-column mapping is `external.columnMap` only.
- **object**: `tags`, `active`, `abstract` — now rejecting tombstones in
`UNKNOWN_KEY_GUIDANCE`.
- **agent**: `tenantId`.

The removed props are dropped from the authoring forms (`field/object/agent.form.ts`)
and the regenerated metadata-forms i18n bundles.

## Corrected to `live` (the ledger was wrong — readers existed)

- **object `isSystem`** — `plugin-sharing` `effectiveSharingModel` defaults a
no-`sharingModel` `isSystem` object to public; also read by the security-posture
lint. KEPT.
- **object `enable.searchable`** — `metadata-protocol` global search (`searchAll`)
uses `enable.searchable === false` as an opt-out. KEPT.
- **action `type:'form'`** — objectui `ActionRunner.executeForm` routes it to the
FormView at `/forms/:target`; a build-time lint validates the target. KEPT.

## Deliberately deferred

`object.enable.trash` / `enable.mru` — dead, but inert `default(true)` flags set by
~35 `sys-*.object.ts` files; removing them is high-churn / low-value. Left `dead`
(authorWarn-skipped).

## Migration

- field/agent props: authoring them was already a no-op; they now strip silently.
`columnName` → the physical column is always the field key (rename the field, or
use `external.columnMap` for external objects); `index` → declare it in object
`indexes[]`; `referenceFilters` → `lookupFilters`.
- object `tags`/`active`/`abstract`: `ObjectSchema.create()` now throws a located
error naming the removal. None gated anything at runtime — remove them.
1 change: 0 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,6 @@ Use `camelCase`:
```typescript
{
maxLength: 100,
referenceFilters: ['active'],
defaultValue: 'none',
}
```
Expand Down
1 change: 0 additions & 1 deletion content/docs/references/ai/agent.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ const result = AIKnowledge.parse(data);
| **active** | `boolean` | ✅ | |
| **access** | `string[]` | optional | Who can chat with this agent |
| **permissions** | `string[]` | optional | Required permission-set capabilities |
| **tenantId** | `string` | optional | Tenant/Organization ID |
| **visibility** | `Enum<'global' \| 'organization' \| 'private'>` | ✅ | [EXPERIMENTAL — NOT ENFORCED, #1901] Intended listing scope. No runtime consumer yet; use access/permissions for real gating. |
| **planning** | `{ maxIterations: integer }` | optional | Autonomous reasoning and planning configuration |
| **memory** | `{ longTerm?: object; reflectionInterval?: integer }` | optional | Agent memory management |
Expand Down
5 changes: 1 addition & 4 deletions content/docs/references/data/field.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,6 @@ const result = Address.parse(data);
| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'toggle' \| 'select' \| 'multiselect' \| 'radio' \| 'checkboxes' \| 'lookup' \| 'master_detail' \| 'tree' \| 'user' \| 'image' \| 'file' \| 'avatar' \| 'video' \| 'audio' \| 'formula' \| 'summary' \| 'autonumber' \| 'composite' \| 'repeater' \| 'record' \| 'location' \| 'address' \| 'code' \| 'json' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode' \| 'progress' \| 'tags' \| 'vector'>` | ✅ | Field Data Type |
| **description** | `string` | optional | Tooltip/Help text |
| **format** | `string` | optional | Format string (e.g. email, phone) |
| **columnName** | `string` | optional | Physical column name in the target datasource. Defaults to the field key when not set. |
| **required** | `boolean` | optional | Is required |
| **searchable** | `boolean` | optional | Is searchable |
| **multiple** | `boolean` | optional | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. |
Expand All @@ -116,7 +115,6 @@ const result = Address.parse(data);
| **max** | `number` | optional | Maximum value |
| **options** | `{ label: string; value: string; color?: string; default?: boolean; … }[]` | optional | Static options for select/multiselect |
| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. |
| **referenceFilters** | `string[]` | optional | Filters applied to lookup dialogs (e.g. "active = true") |
| **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional | What happens if referenced record is deleted |
| **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. |
| **inlineTitle** | `string` | optional | Title for the inline master-detail grid |
Expand All @@ -129,7 +127,7 @@ const result = Address.parse(data);
| **descriptionField** | `string` | optional | Secondary field shown under the label in the quick-select popover. |
| **lookupColumns** | `string \| { field: string; label?: string; width?: string; type?: string }[]` | optional | Explicit columns for the record-picker table; auto-derived from the referenced object when omitted. |
| **lookupPageSize** | `integer` | optional | Rows per page in the record-picker dialog (default 10). |
| **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). Structured, picker-honoured form of referenceFilters. |
| **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. |
| **dependsOn** | `string \| { field: string; param?: string }[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. |
| **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. |
| **expression** | `string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Formula expression (CEL). e.g. F`record.amount * 0.1` |
Expand All @@ -153,7 +151,6 @@ const result = Address.parse(data);
| **sortable** | `boolean` | optional | Whether field is sortable in list views |
| **inlineHelpText** | `string` | optional | Help text displayed below the field in forms |
| **autonumberFormat** | `string` | optional | Auto-number format: literal text + `{0000}` counter, `{YYYY}`/`{MM}`/`{DD}`/`{YYYYMMDD}` date tokens (business tz), and `{field_name}` interpolation. Counter resets per rendered prefix (e.g. AD`{YYYYMMDD}``{0000}` resets daily). |
| **index** | `boolean` | optional | Create standard database index |
| **externalId** | `boolean` | optional | Is external ID for upsert operations |


Expand Down
5 changes: 1 addition & 4 deletions content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,7 @@ const result = ApiMethod.parse(data);
| **pluralLabel** | `string` | optional | Human readable plural label (e.g. "Accounts") |
| **description** | `string` | optional | Developer documentation / description |
| **icon** | `string` | optional | Icon name (Lucide/Material) for UI representation |
| **tags** | `string[]` | optional | Categorization tags (e.g. "sales", "system", "reference") |
| **active** | `boolean` | optional | Is the object active and usable |
| **isSystem** | `boolean` | optional | Is system object (protected from deletion) |
| **abstract** | `boolean` | optional | Is abstract base object (cannot be instantiated) |
| **isSystem** | `boolean` | optional | Is system object (protected from deletion; defaults its org-wide sharing to public when no sharingModel is set — plugin-sharing) |
| **managedBy** | `Enum<'platform' \| 'config' \| 'system' \| 'append-only' \| 'better-auth'>` | optional | Lifecycle bucket — platform (user CRUD) \| config (admin authored) \| system (engine-managed) \| append-only (audit) \| better-auth (identity). UI clients honour the resolved affordance matrix. |
| **ownership** | `Enum<'user' \| 'org' \| 'none'>` | optional | Record-ownership model: user (default — injects reassignable owner_id) \| org \| none (no per-record owner, skips owner_id). Distinct from the package own/extend contribution kind. |
| **userActions** | `{ create?: boolean; import?: boolean; edit?: boolean \| { enabled?: boolean; visibleWhen?: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; disabledWhen?: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } }; delete?: boolean \| { enabled?: boolean; visibleWhen?: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; disabledWhen?: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } }; … }` | optional | Per-object override of the resolved CRUD affordance matrix. |
Expand Down
4 changes: 3 additions & 1 deletion docs/adr/0062-external-datasource-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ This ADR defines the **complete external-datasource runtime contract** and the d
| R7 | Introspection (`remote-tables`, `object-draft`) + runtime Sync wizard | ✅ shipped | ADR-0015 addendum; `service-datasource` |
| R8 | **Connection lifecycle/health/pooling for N datasources** | ❌ **gap** | only the single `default` driver's lifecycle is managed today |
| R9 | dogfood/verify handles read-only external; canonical example | ✅ shipped | #2139 (`verify` skip + `app-showcase`) |
| R10 | **`columnMap` ↔ `field.columnName` single source of truth** | ❌ **gap** | two inverse mechanisms coexist |
| R10 | **`columnMap` ↔ `field.columnName` single source of truth** | ✅ resolved | #2377 removed `field.columnName` (it was never applied by the driver); `external.columnMap` is now the sole physical-column mapping |

### The structural gap, precisely (R2)

Expand Down Expand Up @@ -83,6 +83,8 @@ The analytics native-SQL strategy compiles its own `FROM "<table>"` / column ref

`external.columnMap` ({ remoteColumn → localField }) is the supported way to map external columns (shipped #2149). `field.columnName` (localField → physicalColumn) is its inverse and is **not** applied by the driver's query pipeline for external objects. Decision: for external objects, `columnMap` is authoritative; `field.columnName` on an external object is rejected at validation (no silent dual-source) until a unified column-resolution model is designed. Managed objects' `field.columnName` semantics are untouched.

> **Resolution (#2377, R10 closed).** `field.columnName` was **removed from the spec entirely** (ADR-0049 enforce-or-remove): the SQL driver hardcodes the physical column = field key (`createColumn` never read `columnName`), so it was inert on *managed* objects too — not just external ones. With the field gone there is no dual-source ambiguity, so the build-time D7 rejection lint (`validateStackExpressions`) and the dead `StorageNameMapping.resolveColumnName`/`buildColumnMap`/`buildReverseColumnMap` helpers were removed with it. `external.columnMap` is the single, authoritative physical-column mechanism. Physical-column override for managed objects (e.g. legacy-DB adoption) — the only case with real value — should, if ever needed, be designed as a first-class driver feature rather than reintroduced as an unenforced field.

> **Phase 4 implementation note (#2163).** *D7* is enforced at build time in `validateStackExpressions` (`os compile`/`build`): any object that declares an `external` binding and a field with `columnName` is an error with a corrective message (use `external.columnMap`). Managed objects are untouched. *D8*: `examples/app-showcase` now declares its external datasource with **no** `onEnable` driver registration — `onEnable` only provisions the "remote" fixture tables; the declared datasource auto-connects (D1). To exercise this in the dogfood gate, the `@objectstack/verify` harness now wires the datasource-admin plugin (hence the `'datasource-connection'` service) when an app declares datasources — so the harness matches `objectstack dev`/serve and the federated read is covered end-to-end (a new dogfood test reads `showcase_ext_customer`/`_order`, including the `remoteName` remap). `onEnable` + `ctx.drivers.register` remains documented as the escape hatch for drivers built dynamically at runtime.

### D8 — Drop the `onEnable` bridge from the canonical example; keep it as an escape hatch
Expand Down
46 changes: 14 additions & 32 deletions packages/cli/src/utils/lint-liveness-properties.test.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,26 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import {
lintLivenessProperties,
LIVENESS_DEAD_PROPERTY,
} from './lint-liveness-properties.js';
import { lintLivenessProperties } from './lint-liveness-properties.js';

/**
* These run against the REAL ledgers shipped by `@objectstack/spec` (the same
* files the gate enforces), so they double as a contract test: if an
* `authorWarn` annotation is removed from `abstract` / `columnName` / etc.,
* `authorWarn` annotation is removed from a still-dead prop (e.g. tool
* `permissions`, permission `contextVariables`, flow `nodes.outputSchema`),
* the matching assertion fails.
*/

const objStack = (obj: Record<string, unknown>) => ({ objects: [{ name: 'widget', ...obj }] });
const rules = (findings: { rule: string }[]) => findings.map((f) => f.rule);
const paths = (findings: { message: string }[]) => findings.map((f) => f.message);

describe('lintLivenessProperties', () => {
it('warns on a present dead object prop (abstract)', () => {
const findings = lintLivenessProperties(objStack({ abstract: true }));
const v = findings.find((f) => f.message.includes('abstract'));
expect(v).toBeDefined();
expect(v!.rule).toBe(LIVENESS_DEAD_PROPERTY);
expect(v!.where).toBe("object 'widget'");
expect(v!.hint.length).toBeGreaterThan(0);
});
// NOTE: as of #2377 the object- and field-level dead+authorWarn surface is
// empty (enforce-or-remove complete for those types), so the positive-warn
// assertions here run against still-dead props of OTHER governed types
// (flow.nodes.outputSchema, tool.permissions, permission.contextVariables,
// action.undoable). The object/field WALKER is still exercised by the
// silent-clean and default-on-suppression cases below.

it('does NOT warn on a default-on flag the author left alone (enable.trash: true)', () => {
const findings = lintLivenessProperties(objStack({ enable: { trash: true } }));
Expand All @@ -42,22 +37,6 @@ describe('lintLivenessProperties', () => {
expect(paths(findings).some((m) => m.includes('enable.'))).toBe(false);
});

it('warns on a misleading dead field prop (columnName)', () => {
const findings = lintLivenessProperties(
objStack({ fields: [{ name: 'code', type: 'text', columnName: 'legacy_code' }] }),
);
const f = findings.find((x) => x.message.includes('columnName'));
expect(f).toBeDefined();
expect(f!.where).toBe("object 'widget' · field 'code'");
});

it('warns on a field-level index flag set true, but not when false', () => {
const on = lintLivenessProperties(objStack({ fields: [{ name: 'code', type: 'text', index: true }] }));
expect(paths(on).some((m) => m.includes('`index`'))).toBe(true);
const off = lintLivenessProperties(objStack({ fields: [{ name: 'code', type: 'text', index: false }] }));
expect(paths(off).some((m) => m.includes('`index`'))).toBe(false);
});

it('is silent for a clean object with only live properties', () => {
const findings = lintLivenessProperties(
objStack({
Expand All @@ -70,10 +49,13 @@ describe('lintLivenessProperties', () => {
});

it('handles objects as a keyed record (not just arrays)', () => {
// Record form ({ name: obj }) is walked like the array form — a clean object
// in record form yields no findings and does not throw (no object-level
// dead+authorWarn prop remains to assert a positive on, post-#2377).
const findings = lintLivenessProperties({
objects: { widget: { name: 'widget', abstract: true } },
objects: { widget: { name: 'widget', label: 'Widget', enable: { apiEnabled: true } } },
});
expect(rules(findings)).toContain(LIVENESS_DEAD_PROPERTY);
expect(findings).toEqual([]);
});

it('returns [] on an empty / shapeless stack', () => {
Expand Down
Loading