diff --git a/.changeset/remove-form-surfaced-dead-props-2377.md b/.changeset/remove-form-surfaced-dead-props-2377.md
new file mode 100644
index 0000000000..45e85bb380
--- /dev/null
+++ b/.changeset/remove-form-surfaced-dead-props-2377.md
@@ -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.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 4271c66453..5dfb8fe565 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -181,7 +181,6 @@ Use `camelCase`:
```typescript
{
maxLength: 100,
- referenceFilters: ['active'],
defaultValue: 'none',
}
```
diff --git a/content/docs/references/ai/agent.mdx b/content/docs/references/ai/agent.mdx
index afd9dbf5b9..b044d413e9 100644
--- a/content/docs/references/ai/agent.mdx
+++ b/content/docs/references/ai/agent.mdx
@@ -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 |
diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx
index 563f7cc42d..4ae53eabb6 100644
--- a/content/docs/references/data/field.mdx
+++ b/content/docs/references/data/field.mdx
@@ -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. |
@@ -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 |
@@ -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` |
@@ -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 |
diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx
index f84e4110fe..8b09ac6f19 100644
--- a/content/docs/references/data/object.mdx
+++ b/content/docs/references/data/object.mdx
@@ -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. |
diff --git a/docs/adr/0062-external-datasource-runtime.md b/docs/adr/0062-external-datasource-runtime.md
index 114a9489c7..33b73191fe 100644
--- a/docs/adr/0062-external-datasource-runtime.md
+++ b/docs/adr/0062-external-datasource-runtime.md
@@ -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)
@@ -83,6 +83,8 @@ The analytics native-SQL strategy compiles its own `FROM "
"` / 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
diff --git a/packages/cli/src/utils/lint-liveness-properties.test.ts b/packages/cli/src/utils/lint-liveness-properties.test.ts
index 7608378529..b7704b8b6b 100644
--- a/packages/cli/src/utils/lint-liveness-properties.test.ts
+++ b/packages/cli/src/utils/lint-liveness-properties.test.ts
@@ -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) => ({ 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 } }));
@@ -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({
@@ -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', () => {
diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts
index d940c55a2c..d2c363d530 100644
--- a/packages/lint/src/validate-expressions.test.ts
+++ b/packages/lint/src/validate-expressions.test.ts
@@ -422,57 +422,6 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
});
});
- describe('ADR-0062 D7 — field.columnName on external objects', () => {
- it('rejects field.columnName on a federated (external) object', () => {
- const issues = validateStackExpressions({
- objects: [{
- name: 'ext_customer',
- datasource: 'warehouse',
- external: { remoteName: 'customers' },
- fields: { region: { type: 'text', columnName: 'cust_region' } },
- }],
- });
- const issue = issues.find(i => /columnName/.test(i.message));
- expect(issue).toBeDefined();
- expect(issue!.severity).toBe('error');
- expect(issue!.where).toContain("object 'ext_customer'");
- expect(issue!.where).toContain("field 'region'");
- expect(issue!.message).toMatch(/external\.columnMap|ADR-0062 D7/);
- });
-
- it('rejects field.columnName declared as a map-shaped field too', () => {
- const issues = validateStackExpressions({
- objects: [{
- name: 'ext_order',
- external: {},
- fields: { amount: { type: 'number', columnName: 'amt' } },
- }],
- });
- expect(issues.some(i => /columnName/.test(i.message) && i.where.includes("field 'amount'"))).toBe(true);
- });
-
- it('leaves field.columnName on a MANAGED object untouched (no issue)', () => {
- const issues = validateStackExpressions({
- objects: [{
- name: 'crm_account',
- fields: { region: { type: 'text', columnName: 'acct_region' } },
- }],
- });
- expect(issues.some(i => /columnName/.test(i.message))).toBe(false);
- });
-
- it('passes an external object that uses no columnName', () => {
- const issues = validateStackExpressions({
- objects: [{
- name: 'ext_customer',
- external: { remoteName: 'customers' },
- fields: { region: { type: 'text' } },
- }],
- });
- expect(issues.some(i => /columnName/.test(i.message))).toBe(false);
- });
- });
-
// #3183 — `date` field `== today()` silently never matches (cel-js equality).
// The stack validator threads each object's field types into the shared
// validator, which flags the pattern with an advisory (non-blocking) warning.
@@ -555,4 +504,7 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
expect(dateWarns(issues)).toHaveLength(0);
});
});
+
+ // The ADR-0062 D7 `field.columnName`-on-external-objects lint was removed with
+ // `field.columnName` itself (#2377): external column mapping is `external.columnMap`.
});
diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts
index 3611c6dfdf..255ff68fc4 100644
--- a/packages/lint/src/validate-expressions.ts
+++ b/packages/lint/src/validate-expressions.ts
@@ -184,33 +184,9 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
? (fields as AnyRec[])
: (fields && typeof fields === 'object' ? Object.values(fields as AnyRec) as AnyRec[] : []);
- // ── ADR-0062 D7 — reject `field.columnName` on external objects ──────
- // `field.columnName` (localField → physicalColumn) is the managed-object
- // mechanism; it is NOT applied by the driver's query pipeline for federated
- // objects, where `external.columnMap` (remoteColumn → localField) is the
- // authoritative — and inverse — mapping. Allowing both would be a silent
- // dual-source ambiguity, so reject `columnName` on any object that declares
- // an `external` binding (a federated object). Managed objects are untouched.
- if (obj.external != null) {
- const fieldEntries: Array<[string, AnyRec]> = Array.isArray(fields)
- ? (fields as AnyRec[]).map((f) => [((f as AnyRec)?.name as string) ?? '?', f as AnyRec])
- : (fields && typeof fields === 'object'
- ? (Object.entries(fields as AnyRec) as Array<[string, AnyRec]>)
- : []);
- for (const [fname, fdef] of fieldEntries) {
- if (fdef && typeof fdef === 'object' && (fdef as AnyRec).columnName != null) {
- issues.push({
- where: `object '${objectName}' · field '${fname}'`,
- message:
- `external object '${objectName}': field '${fname}' sets columnName='${String((fdef as AnyRec).columnName)}', ` +
- `which is not supported on federated objects (ADR-0062 D7). The driver's query pipeline ignores ` +
- `field.columnName for external objects; map remote columns via the datasource's external.columnMap instead.`,
- source: `columnName='${String((fdef as AnyRec).columnName)}'`,
- severity: 'error',
- });
- }
- }
- }
+ // (ADR-0062 D7's `field.columnName`-on-external-objects rejection was removed
+ // with `field.columnName` itself in #2377: the field no longer exists, so there
+ // is no dual-source ambiguity to guard — external column mapping is `external.columnMap`.)
for (const f of fieldList) {
// Field-level conditional rules are server-enforced (rule-validator) and
diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts
index 51627d557d..e28bd4d925 100644
--- a/packages/metadata-protocol/src/protocol.ts
+++ b/packages/metadata-protocol/src/protocol.ts
@@ -100,10 +100,7 @@ const HAND_CRAFTED_SCHEMAS: Record> = {
pluralLabel: { type: 'string' },
icon: { type: 'string' },
description: { type: 'string' },
- tags: { type: 'array', items: { type: 'string' } },
- active: { type: 'boolean', default: true },
isSystem: { type: 'boolean', default: false },
- abstract: { type: 'boolean', default: false },
datasource: { type: 'string' },
fields: {
// Canonical Object.fields is a name-keyed map
diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts
index 7bc5e8fa2f..365195eef9 100644
--- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts
+++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts
@@ -50,22 +50,10 @@ export const enMetadataForms: NonNullable = {
label: "Description",
helpText: "Developer documentation"
},
- tags: {
- label: "Tags",
- helpText: "Categorization tags (e.g. \"sales\", \"system\")"
- },
- active: {
- label: "Active",
- helpText: "Is the object active and usable"
- },
isSystem: {
label: "Is System",
helpText: "System object (protected from deletion)"
},
- abstract: {
- label: "Abstract",
- helpText: "Abstract base (cannot be instantiated)"
- },
fields: {
label: "Fields",
helpText: "Add the columns this object will store"
@@ -428,10 +416,6 @@ export const enMetadataForms: NonNullable = {
label: "Reference",
helpText: "Referenced object name"
},
- referenceFilters: {
- label: "Reference Filters",
- helpText: "Filter expressions (e.g., \"active = true\")"
- },
deleteBehavior: {
label: "Delete Behavior",
helpText: "What happens when referenced record is deleted"
@@ -444,14 +428,6 @@ export const enMetadataForms: NonNullable = {
label: "Summary Operations",
helpText: "Roll-up summary configuration (for parent-child relationships)"
},
- columnName: {
- label: "Column Name",
- helpText: "Physical column name in database (defaults to field name)"
- },
- index: {
- label: "Index",
- helpText: "Create database index for faster queries"
- },
externalId: {
label: "External Id",
helpText: "Mark as external ID for upsert operations"
@@ -1652,10 +1628,6 @@ export const enMetadataForms: NonNullable = {
label: "Permissions",
helpText: "Required permissions to use this agent"
},
- tenantId: {
- label: "Tenant Id",
- helpText: "Restrict to specific organization ID"
- },
guardrails: {
label: "Guardrails",
helpText: "Safety rules and content policies"
diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts
index b6b8a995c4..6a2c0d1b54 100644
--- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts
+++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts
@@ -50,22 +50,10 @@ export const esESMetadataForms: NonNullable =
label: "Descripción",
helpText: "Documentación para desarrolladores"
},
- tags: {
- label: "Etiquetas",
- helpText: "Etiquetas de categorización (p. ej. \"sales\", \"system\")"
- },
- active: {
- label: "Activo",
- helpText: "Indica si el objeto está activo y usable"
- },
isSystem: {
label: "Integrado del sistema",
helpText: "Objeto de sistema (protegido contra eliminación)"
},
- abstract: {
- label: "Abstracto",
- helpText: "Base abstracta (no se puede instanciar)"
- },
fields: {
label: "Campos",
helpText: "Añade las columnas que almacenará este objeto"
@@ -428,10 +416,6 @@ export const esESMetadataForms: NonNullable =
label: "Referencia",
helpText: "Nombre del objeto referenciado"
},
- referenceFilters: {
- label: "Filtros de referencia",
- helpText: "Expresiones de filtro (p. ej., \"active = true\")"
- },
deleteBehavior: {
label: "Comportamiento al eliminar",
helpText: "Qué ocurre cuando se elimina el registro referenciado"
@@ -444,14 +428,6 @@ export const esESMetadataForms: NonNullable =
label: "Operaciones de resumen",
helpText: "Configuración de resumen roll-up (para relaciones padre-hijo)"
},
- columnName: {
- label: "Nombre de columna",
- helpText: "Nombre de columna física en la base de datos (por defecto, el nombre del campo)"
- },
- index: {
- label: "Índice",
- helpText: "Crea un índice de base de datos para consultas más rápidas"
- },
externalId: {
label: "ID externo",
helpText: "Marca como ID externo para operaciones upsert"
@@ -1652,10 +1628,6 @@ export const esESMetadataForms: NonNullable =
label: "Permisos",
helpText: "Permisos necesarios para usar este agente"
},
- tenantId: {
- label: "ID de tenant",
- helpText: "Restringe a un ID de organization específico"
- },
guardrails: {
label: "Reglas de protección",
helpText: "Reglas de seguridad y políticas de contenido"
diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts
index 24e10a242f..5ecee91613 100644
--- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts
+++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts
@@ -50,22 +50,10 @@ export const jaJPMetadataForms: NonNullable =
label: "説明",
helpText: "開発者向けドキュメント"
},
- tags: {
- label: "タグ",
- helpText: "分類タグ(例: \"sales\", \"system\")"
- },
- active: {
- label: "有効",
- helpText: "オブジェクトが有効で使用可能か"
- },
isSystem: {
label: "システム組み込み",
helpText: "システムオブジェクト(削除から保護)"
},
- abstract: {
- label: "抽象",
- helpText: "抽象ベース(インスタンス化不可)"
- },
fields: {
label: "フィールド",
helpText: "このオブジェクトが保存する列を追加"
@@ -428,10 +416,6 @@ export const jaJPMetadataForms: NonNullable =
label: "参照",
helpText: "参照先オブジェクト名"
},
- referenceFilters: {
- label: "参照フィルター",
- helpText: "フィルター式(例: \"active = true\")"
- },
deleteBehavior: {
label: "削除動作",
helpText: "参照先レコード削除時の動作"
@@ -444,14 +428,6 @@ export const jaJPMetadataForms: NonNullable =
label: "集計操作",
helpText: "ロールアップ集計設定(親子関係用)"
},
- columnName: {
- label: "列名",
- helpText: "データベース上の物理列名(既定はフィールド名)"
- },
- index: {
- label: "インデックス",
- helpText: "高速クエリ用のデータベースインデックスを作成"
- },
externalId: {
label: "外部 ID",
helpText: "upsert 操作用の外部 ID としてマーク"
@@ -1652,10 +1628,6 @@ export const jaJPMetadataForms: NonNullable =
label: "権限",
helpText: "このエージェントの使用に必要な権限"
},
- tenantId: {
- label: "テナント ID",
- helpText: "特定 organization ID に制限"
- },
guardrails: {
label: "ガードレール",
helpText: "安全ルールとコンテンツポリシー"
diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts
index 58860d8449..20d269a38b 100644
--- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts
+++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts
@@ -50,22 +50,10 @@ export const zhCNMetadataForms: NonNullable =
label: "描述",
helpText: "开发文档说明"
},
- tags: {
- label: "标签",
- helpText: "分类标签(如:\"sales\"、\"system\")"
- },
- active: {
- label: "启用",
- helpText: "对象是否启用并可用"
- },
isSystem: {
label: "系统内置",
helpText: "系统对象(受保护,不可删除)"
},
- abstract: {
- label: "抽象",
- helpText: "抽象基类(不能直接实例化)"
- },
fields: {
label: "字段",
helpText: "添加该对象将存储的列"
@@ -428,10 +416,6 @@ export const zhCNMetadataForms: NonNullable =
label: "引用对象",
helpText: "被引用的对象名称"
},
- referenceFilters: {
- label: "引用过滤",
- helpText: "筛选表达式(如:active = true)"
- },
deleteBehavior: {
label: "删除行为",
helpText: "被引用记录删除时的处理方式"
@@ -444,14 +428,6 @@ export const zhCNMetadataForms: NonNullable =
label: "汇总操作",
helpText: "父子关系下的汇总聚合配置"
},
- columnName: {
- label: "列名",
- helpText: "数据库中的物理列名(默认与字段名相同)"
- },
- index: {
- label: "索引",
- helpText: "建立数据库索引以加速查询"
- },
externalId: {
label: "外部 ID",
helpText: "标记为外部 ID 用于 upsert 操作"
@@ -1652,10 +1628,6 @@ export const zhCNMetadataForms: NonNullable =
label: "权限",
helpText: "使用此代理所需的权限"
},
- tenantId: {
- label: "租户 ID",
- helpText: "限定到特定组织"
- },
guardrails: {
label: "护栏",
helpText: "安全规则与内容策略"
diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md
index c79fd06faf..a34cdcc880 100644
--- a/packages/spec/liveness/README.md
+++ b/packages/spec/liveness/README.md
@@ -189,14 +189,14 @@ EOF
| Type | live | exp | dead | planned | Notes |
|---|---|---|---|---|---|
-| object | 37 | – | 7 | 1 | aspirational tier (versioning/softDelete/search/recordName/keyPrefix) REMOVED in 16.0 (#2377) — tombstoned in UNKNOWN_KEY_GUIDANCE; remaining dead = tags/active/isSystem/abstract + enable.searchable/trash/mru (authoring-form-surfaced, deferred); ObjectCapabilities otherwise live post-#2707/#2727; `tenancy.strategy`/`crossTenantAccess` REMOVED post-15.0 (#2763) |
-| field | 55 | – | 3 | – | near-healthy; vectorConfig/fileAttachmentConfig/dependencies REMOVED in 16.0 (#2377); remaining dead = referenceFilters/columnName/index (authoring-form-surfaced, deferred), all authorWarn'd |
+| object | 40 | – | 2 | 1 | aspirational tier (versioning/softDelete/search/recordName/keyPrefix) + tags/active/abstract REMOVED (#2377) — tombstoned in UNKNOWN_KEY_GUIDANCE; `isSystem` + `enable.searchable` CORRECTED to live (#2377 — sharing default-model + global-search opt-out; 2026-06 audit missed both readers); remaining dead = `enable.trash`/`mru` (inert default-true, ~35 sys-object setters → deferred); `tenancy.strategy`/`crossTenantAccess` REMOVED post-15.0 (#2763) |
+| field | 55 | – | 0 | – | healthy — full dead set (vectorConfig/fileAttachmentConfig/dependencies, then referenceFilters/columnName/index) REMOVED (#2377); columnName also dropped the ADR-0062 D7 lint + StorageNameMapping column helpers |
| flow | 27 | – | 4 | – | dead = description/template/nodes.outputSchema/errorHandling.fallbackNodeId (engine uses fault edges) |
-| action | 35 | 1 | 0 | – | `disabled` went LIVE via metadata-admin authoring UI (2026-06 audit missed objectui); dead `timeout` REMOVED in 16.0 (#2377) |
+| action | 35 | 1 | 0 | – | `disabled` went LIVE via metadata-admin authoring UI (2026-06 audit missed objectui); `type:'form'` CORRECTED to live (objectui ActionRunner.executeForm, #2377); dead `timeout` REMOVED (#2377) |
| hook | 11 | – | 2 | – | model-healthy; only label/description dead (benign) |
| permission | 32 | – | 1 | – | CRUD/FLS/RLS live; `contextVariables` dead (RLS uses current_user.* built-ins only) |
| position | 4 | – | – | – | (role's ADR-0090 successor) fully live |
-| agent | 14 | 5 | 1 | – | `tenantId` dead (authoring-form-surfaced, deferred); dead `planning.strategy`/`allowReplan` REMOVED in 16.0 (#2377), only `planning.maxIterations` live; autonomy tier experimental |
+| agent | 14 | 5 | 0 | – | dead `tenantId` + `planning.strategy`/`allowReplan` REMOVED (#2377) — only `planning.maxIterations` live; autonomy tier experimental |
| tool | 9 | 1 | 1 | – | `permissions` dead — tool invocation not permission-gated by it |
| skill | 10 | – | – | – | fully live |
| dataset | 19 | – | 0 | – | `measures.certified` (declared-but-unenforced governance flag) REMOVED in 16.0 (#2377) |
diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json
index 81765c0300..7965e896ee 100644
--- a/packages/spec/liveness/action.json
+++ b/packages/spec/liveness/action.json
@@ -30,7 +30,7 @@
"type": {
"status": "live",
"evidence": "packages/runtime/src/http-dispatcher.ts",
- "note": "api/script/flow wired; url thinner; modal PARTIAL (maps to serverActionHandler, not a real modal); form DEAD (no consumer)."
+ "note": "api/script/flow wired; url thinner; modal PARTIAL (maps to serverActionHandler, not a real modal); form LIVE via objectui ActionRunner.executeForm (routes a type:'form' action to the FormView at /forms/:target, forwarding the current record id) — the 2026-06 audit mis-classified as dead (objectui renderer not re-verified; fixed the 'Log Time does nothing' report). Build-time lint-view-refs.ts validates the form target resolves to a form view."
},
"target": {
"status": "live",
diff --git a/packages/spec/liveness/agent.json b/packages/spec/liveness/agent.json
index d00ef8389b..b5c8372759 100644
--- a/packages/spec/liveness/agent.json
+++ b/packages/spec/liveness/agent.json
@@ -69,12 +69,6 @@
"evidence": "packages/services/service-ai/src/routes/agent-access.ts:41",
"note": "intentionally NOT enforced — the chat-access evaluator excludes it and the GET /ai/agents list route does not filter by visibility. Needs owner/org semantics before it can gate listing."
},
- "tenantId": {
- "status": "dead",
- "evidence": "no runtime reader",
- "authorWarn": true,
- "authorHint": "No runtime reader — it does NOT scope the agent to a tenant. Tenancy comes from the request context (resolveAuthzContext), not this field."
- },
"knowledge": {
"status": "live",
"evidence": "objectui: packages/app-shell/src/views/metadata-admin/previews/AgentPreview.tsx:69 (d.knowledge; KnowledgeSummary renders sources/indexes)",
diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json
index 134d71d9b2..148c679989 100644
--- a/packages/spec/liveness/field.json
+++ b/packages/spec/liveness/field.json
@@ -165,12 +165,6 @@
"evidence": "packages/objectql/src/validation/record-validator.ts:130",
"note": "CAVEAT — server camel; client form reads min_length."
},
- "referenceFilters": {
- "status": "dead",
- "evidence": "lookup dialog reads lookup_filters/lookupFilters (LookupField.tsx) — the string[] referenceFilters form is not read (naming drift)",
- "authorWarn": true,
- "authorHint": "The picker reads the structured `lookupFilters` ({field,operator,value}), not the string[] `referenceFilters` — as authored this filters nothing. Use `lookupFilters`, or remove this to avoid implying a constrained lookup."
- },
"displayField": {
"status": "live",
"note": "objectui LookupField/RecordPickerDialog — candidate label field in the record picker (reads displayField || display_field)."
@@ -199,24 +193,11 @@
"status": "live",
"note": "objectui LookupField — opt-in inline quick-create (dataSource.create from typed text) (reads allowCreate || allow_create)."
},
- "columnName": {
- "status": "dead",
- "evidence": "resolveColumnName (spec system-names.ts:182) has ZERO call sites; SQL driver hardcodes column = field key",
- "note": "DANGEROUS — advertises custom physical columns the driver never honors.",
- "authorWarn": true,
- "authorHint": "The physical column always equals the field key — a custom `columnName` is silently ignored by the driver. Remove it; rename the field key itself if you need a different column."
- },
"searchable": {
"status": "live",
"evidence": "objectui: packages/plugin-dashboard/src/WidgetConfigPanel.tsx:458",
"note": "LIVE via objectui renderer — the 2026-06 audit mis-classified as dead (renderer side not re-verified). Corrected after checking ../objectui."
},
- "index": {
- "status": "dead",
- "evidence": "field-level — driver reads object indexes[] (sql-driver.ts:1252); field bool unused",
- "authorWarn": true,
- "authorHint": "A field-level `index: true` creates no index — the driver builds indexes from the object's `indexes[]` array. Declare the index there instead."
- },
"externalId": {
"status": "live",
"evidence": "objectui: apps/console/src/utils/metadataConverters.ts:125 reads field.externalId",
diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json
index 24bd073850..1e9a020b91 100644
--- a/packages/spec/liveness/object.json
+++ b/packages/spec/liveness/object.json
@@ -161,9 +161,9 @@
"evidence": "objectui app-shell RecordDetailView gates the record History tab on enable.trackHistory === true (historyEnabled memo); pairs with per-field trackHistory (plugin-audit renderTrackedChangeSummary, packages/plugins/plugin-audit/src/audit-writers.ts) which selects the field diffs. Audit CAPTURE into sys_audit_log is deliberately unconditional (compliance ledger) — the flag gates the UI surface, not the ledger (#2707)."
},
"searchable": {
- "status": "dead",
- "evidence": "no behavior-changing reader",
- "_authorWarnSkipped": "defaults to true in the schema — the lint can't distinguish author-set-true from the default, so warning here would fire on every object with an enable block. Only default-FALSE booleans are safe to authorWarn."
+ "status": "live",
+ "evidence": "packages/metadata-protocol/src/protocol.ts:2967",
+ "note": "Global Search (searchAll, M10.5): `enable.searchable === false` opts the object out of cross-object search. Behavior-changing reader — the 2026-06 audit mis-classified as dead (search executor not re-verified). default(true), so still not authorWarn'd."
},
"files": {
"status": "live",
@@ -192,23 +192,10 @@
}
}
},
- "tags": {
- "status": "dead",
- "evidence": "no runtime reader"
- },
- "abstract": {
- "status": "dead",
- "evidence": "no runtime reader",
- "authorWarn": true,
- "authorHint": "No runtime reader — an abstract object still gets a table and is instantiable. Object inheritance/abstraction is not implemented."
- },
"isSystem": {
- "status": "dead",
- "evidence": "object-level — no runtime reader"
- },
- "active": {
- "status": "dead",
- "evidence": "object-level — no runtime reader"
+ "status": "live",
+ "evidence": "packages/plugins/plugin-sharing/src/sharing-service.ts:74",
+ "note": "effectiveSharingModel: an object with no sharingModel and isSystem===true defaults to 'public' (else 'private') — fail-closed org-wide-default posture; ORed with the sys_ name-prefix fallback. Also read by lint/src/validate-security-posture.ts:98 (isSystemObject exempts system objects from the master-detail CRUD-grant lint) and mirrored in metadata-protocol/src/protocol.ts:106. The 2026-06 audit mis-classified as dead (sharing/lint readers not re-verified)."
},
"searchableFields": {
"status": "live",
diff --git a/packages/spec/src/ai/agent.form.ts b/packages/spec/src/ai/agent.form.ts
index 18c8cf4576..3ff50a2186 100644
--- a/packages/spec/src/ai/agent.form.ts
+++ b/packages/spec/src/ai/agent.form.ts
@@ -56,7 +56,6 @@ export const agentForm = defineForm({
{ field: 'visibility', helpText: 'EXPERIMENTAL — not enforced yet (#1901): setting "private" does not hide the agent. Use Access / Permissions below for real gating.' },
{ field: 'access', widget: 'string-tags', helpText: 'User IDs or role names who can chat with this agent' },
{ field: 'permissions', widget: 'string-tags', helpText: 'Required permissions to use this agent' },
- { field: 'tenantId', helpText: 'Restrict to specific organization ID' },
{ field: 'guardrails', type: 'composite', helpText: 'Safety rules and content policies' },
],
},
diff --git a/packages/spec/src/ai/agent.zod.ts b/packages/spec/src/ai/agent.zod.ts
index e9a7b6a25f..4958dc11c2 100644
--- a/packages/spec/src/ai/agent.zod.ts
+++ b/packages/spec/src/ai/agent.zod.ts
@@ -167,8 +167,9 @@ export const AgentSchema = lazySchema(() => z.object({
/** Permission-set capabilities required to use this agent */
permissions: z.array(z.string()).optional().describe('Required permission-set capabilities'),
- /** Multi-tenancy & Visibility */
- tenantId: z.string().optional().describe('Tenant/Organization ID'),
+ // `tenantId` removed in the 16.x line (#2377, ADR-0049): it had no runtime
+ // reader and did NOT scope the agent to a tenant — tenancy comes from the
+ // request context (resolveAuthzContext), not this field.
// ⚠️ EXPERIMENTAL — NOT ENFORCED (#1901, ADR-0049). The chat-access evaluator
// deliberately excludes `visibility` (agent-access.ts) and the agent list
// route does not filter by it — setting `private` does NOT hide the agent.
diff --git a/packages/spec/src/data/field.form.ts b/packages/spec/src/data/field.form.ts
index 27dd5c96d8..2ea92df552 100644
--- a/packages/spec/src/data/field.form.ts
+++ b/packages/spec/src/data/field.form.ts
@@ -45,7 +45,6 @@ export const fieldForm = defineForm({
{ field: 'options', type: 'repeater', visibleWhen: "data.type == 'select' || data.type == 'multiselect'", helpText: 'Available options (label/value pairs)' },
// Reference field options
{ field: 'reference', widget: 'ref:object', visibleWhen: "data.type == 'lookup' || data.type == 'master_detail'", helpText: 'Referenced object name' },
- { field: 'referenceFilters', widget: 'string-tags', visibleWhen: "data.type == 'lookup' || data.type == 'master_detail'", helpText: 'Filter expressions (e.g., "active = true")' },
{ field: 'deleteBehavior', visibleWhen: "data.type == 'lookup' || data.type == 'master_detail'", helpText: 'What happens when referenced record is deleted' },
],
},
@@ -69,8 +68,6 @@ export const fieldForm = defineForm({
columns: 2,
fields: [
// Database & Performance
- { field: 'columnName', colSpan: 2, helpText: 'Physical column name in database (defaults to field name)' },
- { field: 'index', colSpan: 1, helpText: 'Create database index for faster queries' },
{ field: 'externalId', colSpan: 1, helpText: 'Mark as external ID for upsert operations' },
// UI & Visibility
{ field: 'readonly', colSpan: 1, helpText: 'Field is read-only in forms' },
diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts
index 65621f6ab2..4ff5dc3b98 100644
--- a/packages/spec/src/data/field.test.ts
+++ b/packages/spec/src/data/field.test.ts
@@ -204,7 +204,6 @@ describe('FieldSchema', () => {
expect(result.unique).toBe(false);
expect(result.hidden).toBe(false);
expect(result.readonly).toBe(false);
- expect(result.index).toBe(false);
expect(result.externalId).toBe(false);
});
});
@@ -262,7 +261,6 @@ describe('FieldSchema', () => {
label: 'Account',
type: 'lookup',
reference: 'account',
- referenceFilters: ['status = "active"'],
};
expect(() => FieldSchema.parse(lookupField)).not.toThrow();
@@ -423,7 +421,6 @@ describe('FieldSchema', () => {
label: 'Email',
type: 'email',
unique: true,
- index: true,
externalId: true,
};
@@ -443,7 +440,6 @@ describe('FieldSchema', () => {
maxLength: 20,
minLength: 10,
defaultValue: 'ACC-0000',
- index: true,
externalId: true,
readonly: false,
hidden: false,
@@ -528,7 +524,6 @@ describe('Field Factory Helpers', () => {
it('should create lookup field', () => {
const lookupField = Field.lookup('account', {
label: 'Account',
- referenceFilters: ['status = "active"'],
});
expect(lookupField.type).toBe('lookup');
@@ -969,35 +964,6 @@ describe('FieldSchema - conditional field rules (visibleWhen / readonlyWhen / re
});
});
-// ============================================================================
-// columnName — Storage Layer Mapping
-// ============================================================================
-
-describe('FieldSchema - columnName', () => {
- it('should accept columnName for storage layer mapping', () => {
- const result = FieldSchema.parse({
- type: 'text',
- columnName: 'user_email',
- });
- expect(result.columnName).toBe('user_email');
- });
-
- it('should accept field without columnName (optional, defaults to key)', () => {
- const result = FieldSchema.parse({
- type: 'text',
- });
- expect(result.columnName).toBeUndefined();
- });
-
- it('should accept camelCase columnName for legacy DB compatibility', () => {
- const result = FieldSchema.parse({
- type: 'datetime',
- columnName: 'expiresAt',
- });
- expect(result.columnName).toBe('expiresAt');
- });
-});
-
describe('ADR-0066 D3 — field-level requiredPermissions', () => {
it('FieldSchema accepts requiredPermissions', () => {
diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts
index 76869d62fa..3bab92fd4f 100644
--- a/packages/spec/src/data/field.zod.ts
+++ b/packages/spec/src/data/field.zod.ts
@@ -252,8 +252,10 @@ export const FieldSchema = lazySchema(() => z.object({
description: z.string().optional().describe('Tooltip/Help text'),
format: z.string().optional().describe('Format string (e.g. email, phone)'),
- /** Storage Layer Mapping */
- columnName: z.string().optional().describe('Physical column name in the target datasource. Defaults to the field key when not set.'),
+ // `columnName` removed in the 16.x line (#2377, ADR-0049): the SQL driver
+ // hardcodes the physical column = field key (createColumn never reads it), so
+ // a custom column name was silently ignored. External/federated objects map
+ // physical columns via `external.columnMap` (ADR-0062 D7 / ADR-0015).
/** Database Constraints */
required: z.boolean().default(false).describe('Is required'),
@@ -290,7 +292,9 @@ export const FieldSchema = lazySchema(() => z.object({
'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: z.array(z.string()).optional().describe('Filters applied to lookup dialogs (e.g. "active = true")'),
+ // `referenceFilters` (string[]) removed in the 16.x line (#2377, ADR-0049):
+ // the lookup picker reads the structured `lookupFilters` ({field,operator,value}),
+ // never this string[] form — as authored it filtered nothing. Use `lookupFilters`.
deleteBehavior: z.enum(['set_null', 'cascade', 'restrict']).optional().default('set_null').describe('What happens if referenced record is deleted'),
/**
* Master-detail INLINE EDITING. On a child's `master_detail`/`lookup` field
@@ -375,7 +379,7 @@ export const FieldSchema = lazySchema(() => z.object({
field: z.string(),
operator: z.enum(['eq', 'ne', 'gt', 'lt', 'gte', 'lte', 'contains', 'in', 'notIn']),
value: z.any(),
- })).optional().describe('Base filters restricting which records are selectable (e.g. only active). Structured, picker-honoured form of referenceFilters.'),
+ })).optional().describe('Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter.'),
dependsOn: z.array(z.union([z.string(), z.object({
field: z.string(),
param: z.string().optional(),
@@ -509,8 +513,9 @@ export const FieldSchema = lazySchema(() => z.object({
* optional field → warning).
*/
autonumberFormat: z.string().optional().describe('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).'),
- /** Indexing */
- index: z.boolean().default(false).describe('Create standard database index'),
+ // `index` (field-level bool) removed in the 16.x line (#2377, ADR-0049): the
+ // driver builds indexes from the object's `indexes[]` array; a field-level
+ // `index: true` created no index. Declare the index in object `indexes[]`.
externalId: z.boolean().default(false).describe('Is external ID for upsert operations'),
}));
diff --git a/packages/spec/src/data/object.form.ts b/packages/spec/src/data/object.form.ts
index 3cdd436455..28671612de 100644
--- a/packages/spec/src/data/object.form.ts
+++ b/packages/spec/src/data/object.form.ts
@@ -20,10 +20,7 @@ export const objectForm = defineForm({
{ field: 'pluralLabel', type: 'text', colSpan: 1, helpText: 'Plural display name (e.g. "Accounts")' },
{ field: 'icon', type: 'text', colSpan: 1, helpText: 'Lucide icon name (e.g. "building", "users")' },
{ field: 'description', type: 'textarea', colSpan: 2, helpText: 'Developer documentation' },
- { field: 'tags', type: 'tags', colSpan: 2, helpText: 'Categorization tags (e.g. "sales", "system")' },
- { field: 'active', type: 'boolean', colSpan: 1, helpText: 'Is the object active and usable' },
- { field: 'isSystem', type: 'boolean', colSpan: 1, helpText: 'System object (protected from deletion)' },
- { field: 'abstract', type: 'boolean', colSpan: 1, helpText: 'Abstract base (cannot be instantiated)' },
+ { field: 'isSystem', type: 'boolean', colSpan: 1, helpText: 'System object (protected from deletion; defaults sharing to public)' },
],
},
{
diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts
index 8675f4f147..bdeb0655da 100644
--- a/packages/spec/src/data/object.test.ts
+++ b/packages/spec/src/data/object.test.ts
@@ -412,26 +412,6 @@ describe('ObjectSchema', () => {
expect(() => ObjectSchema.parse(fullObject)).not.toThrow();
});
-
- it('should accept object with field-level columnName for storage decoupling', () => {
- const object = ObjectSchema.parse({
- name: 'user',
- fields: {
- email: {
- type: 'email',
- columnName: 'email_address',
- },
- created_at: {
- type: 'datetime',
- columnName: 'createdAt',
- },
- },
- });
-
- expect(object.name).toBe('user');
- expect(object.fields.email.columnName).toBe('email_address');
- expect(object.fields.created_at.columnName).toBe('createdAt');
- });
});
describe('Object with Indexes', () => {
@@ -817,9 +797,7 @@ describe('ObjectSchema.create()', () => {
title: { type: 'text' },
},
});
- expect(result.active).toBe(true);
expect(result.isSystem).toBe(false);
- expect(result.abstract).toBe(false);
expect(result.datasource).toBe('default');
});
diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts
index acd3772e86..24826ccc7b 100644
--- a/packages/spec/src/data/object.zod.ts
+++ b/packages/spec/src/data/object.zod.ts
@@ -552,10 +552,12 @@ const ObjectSchemaBase = z.object({
/**
* Taxonomy & Organization
*/
- tags: z.array(z.string()).optional().describe('Categorization tags (e.g. "sales", "system", "reference")'),
- active: z.boolean().optional().default(true).describe('Is the object active and usable'),
- isSystem: z.boolean().optional().default(false).describe('Is system object (protected from deletion)'),
- abstract: z.boolean().optional().default(false).describe('Is abstract base object (cannot be instantiated)'),
+ // `tags`, `active`, `abstract` removed in the 16.x line (#2377, ADR-0049):
+ // no runtime reader (an "inactive"/"abstract" object still got a table and was
+ // fully usable; tags were never consumed). `isSystem` STAYS — it is live:
+ // plugin-sharing effectiveSharingModel defaults a no-sharingModel isSystem
+ // object to public, and the security-posture lint exempts system objects.
+ isSystem: z.boolean().optional().default(false).describe('Is system object (protected from deletion; defaults its org-wide sharing to public when no sharingModel is set — plugin-sharing)'),
/**
* Managed-by hint — declares which lifecycle bucket the object belongs
@@ -1094,6 +1096,18 @@ const UNKNOWN_KEY_GUIDANCE: Record = {
'`keyPrefix` was removed from the spec in 16.0 (#2377, ADR-0049) — record ' +
'ids are not prefixed from it (no Salesforce-style key-prefix runtime). ' +
'Remove the key; it had no effect.',
+ tags:
+ '`tags` (object-level categorization) was removed from the spec (#2377, ' +
+ 'ADR-0049) — it had no runtime reader. Remove the key; use `managedBy` for ' +
+ 'lifecycle bucketing or a real field for per-record tagging.',
+ active:
+ '`active` was removed from the spec (#2377, ADR-0049) — no runtime reader ' +
+ 'gated on it, so an "inactive" object was still fully queryable and usable. ' +
+ 'Remove the key; gate availability with permissions/sharing instead.',
+ abstract:
+ '`abstract` was removed from the spec (#2377, ADR-0049) — object ' +
+ 'inheritance/abstraction is not implemented, so an abstract object still ' +
+ 'got a table and was instantiable. Remove the key.',
};
/** Levenshtein edit distance — backs the "did you mean" hint for typo'd keys. */
diff --git a/packages/spec/src/system/constants/system-names.test.ts b/packages/spec/src/system/constants/system-names.test.ts
index 76d51374b9..cb171c496b 100644
--- a/packages/spec/src/system/constants/system-names.test.ts
+++ b/packages/spec/src/system/constants/system-names.test.ts
@@ -114,76 +114,4 @@ describe('StorageNameMapping', () => {
expect(StorageNameMapping.resolveTableName({ name: 'sys_audit_log' })).toBe('sys_audit_log');
});
});
-
- describe('resolveColumnName', () => {
- it('should return columnName when specified', () => {
- expect(StorageNameMapping.resolveColumnName('user_id', { columnName: 'userId' })).toBe('userId');
- });
-
- it('should fall back to fieldKey when columnName is not set', () => {
- expect(StorageNameMapping.resolveColumnName('user_id', {})).toBe('user_id');
- });
-
- it('should fall back to fieldKey when columnName is undefined', () => {
- expect(StorageNameMapping.resolveColumnName('email', { columnName: undefined })).toBe('email');
- });
- });
-
- describe('buildColumnMap', () => {
- it('should build a complete field-key → column-name map', () => {
- const fields = {
- user_id: { columnName: 'userId' },
- email: {},
- expires_at: { columnName: 'expiresAt' },
- };
-
- const map = StorageNameMapping.buildColumnMap(fields);
-
- expect(map).toEqual({
- user_id: 'userId',
- email: 'email',
- expires_at: 'expiresAt',
- });
- });
-
- it('should return empty map for empty fields', () => {
- expect(StorageNameMapping.buildColumnMap({})).toEqual({});
- });
- });
-
- describe('buildReverseColumnMap', () => {
- it('should build a reverse column-name → field-key map', () => {
- const fields = {
- user_id: { columnName: 'userId' },
- email: {},
- expires_at: { columnName: 'expiresAt' },
- };
-
- const reverseMap = StorageNameMapping.buildReverseColumnMap(fields);
-
- expect(reverseMap).toEqual({
- userId: 'user_id',
- email: 'email',
- expiresAt: 'expires_at',
- });
- });
-
- it('should return empty map for empty fields', () => {
- expect(StorageNameMapping.buildReverseColumnMap({})).toEqual({});
- });
-
- it('should handle all fields without columnName (identity mapping)', () => {
- const fields = {
- name: {},
- status: {},
- };
-
- const reverseMap = StorageNameMapping.buildReverseColumnMap(fields);
-
- expect(reverseMap).toEqual({
- name: 'name',
- status: 'status',
- });
- });
- });
});
diff --git a/packages/spec/src/system/constants/system-names.ts b/packages/spec/src/system/constants/system-names.ts
index 3d86fcbdd7..846de8d554 100644
--- a/packages/spec/src/system/constants/system-names.ts
+++ b/packages/spec/src/system/constants/system-names.ts
@@ -114,7 +114,9 @@ export type SystemUserId = typeof SystemUserId[keyof typeof SystemUserId];
* All API calls, SDK references, and permission checks MUST use these constants
* instead of hardcoded strings or physical column names.
*
- * The actual storage column name may differ via `FieldSchema.columnName`.
+ * The physical storage column always equals the field key (the driver does not
+ * support per-field column overrides; external objects map columns via
+ * `external.columnMap`, ADR-0062 D7 / ADR-0015).
*
* @example
* ```ts
@@ -184,45 +186,8 @@ export const StorageNameMapping = {
return idx === -1 ? object.name : object.name.slice(idx + 2);
},
- /**
- * Resolve the physical column name for a field.
- * Falls back to `fieldKey` when `columnName` is not set on the field.
- *
- * @param fieldKey - The protocol-level field key (snake_case identifier).
- * @param field - Field definition (at minimum `{ columnName?: string }`).
- * @returns The physical column name to use in storage operations.
- */
- resolveColumnName(fieldKey: string, field: { columnName?: string }): string {
- return field.columnName ?? fieldKey;
- },
-
- /**
- * Build a complete field-key → column-name map for an entire object.
- *
- * @param fields - The fields record from an ObjectSchema.
- * @returns A record mapping every protocol field key to its physical column name.
- */
- buildColumnMap(fields: Record): Record {
- const map: Record = {};
- for (const key of Object.keys(fields)) {
- map[key] = fields[key].columnName ?? key;
- }
- return map;
- },
-
- /**
- * Build a reverse column-name → field-key map for an entire object.
- * Useful for translating storage-layer results back to protocol-level field keys.
- *
- * @param fields - The fields record from an ObjectSchema.
- * @returns A record mapping every physical column name back to its protocol field key.
- */
- buildReverseColumnMap(fields: Record): Record {
- const map: Record = {};
- for (const key of Object.keys(fields)) {
- const col = fields[key].columnName ?? key;
- map[col] = key;
- }
- return map;
- },
+ // resolveColumnName / buildColumnMap / buildReverseColumnMap were removed with
+ // `field.columnName` (#2377, ADR-0049): the SQL driver hardcodes the physical
+ // column = field key, so these had zero call sites. External-object physical
+ // mapping lives in `external.columnMap` (ADR-0062 D7 / ADR-0015).
} as const;
diff --git a/packages/spec/src/ui/widget.test.ts b/packages/spec/src/ui/widget.test.ts
index a49606420a..1c23dc4f77 100644
--- a/packages/spec/src/ui/widget.test.ts
+++ b/packages/spec/src/ui/widget.test.ts
@@ -182,7 +182,6 @@ describe('FieldWidgetPropsSchema', () => {
onChange: () => {},
field: Field.lookup('account', {
label: 'Account',
- referenceFilters: ['status = "active"'],
}),
};