Skip to content

Commit 5754a23

Browse files
os-zhuangclaude
andauthored
feat(spec)!: remove form-surfaced dead metadata props + correct 3 misclassified-live entries (#2377) (#3199)
* feat(spec)!: remove form-surfaced dead metadata props + correct 3 misclassified-live entries (#2377) Next enforce-or-remove slice (ADR-0049), versioned minor per launch-window policy. Removed (dead in both framework + objectui): - field: columnName, index, referenceFilters (empties the field dead set). columnName also drops the moot ADR-0062 D7 lint, the dead StorageNameMapping column helpers, and closes ADR-0062 R10 (external mapping = external.columnMap). - object: tags, active, abstract (now rejecting tombstones). - agent: tenantId. Dropped from the authoring forms + regenerated metadata-forms i18n bundles; reference docs + ledger README regenerated. Corrected to live (ledger was wrong — readers existed, verified): - object isSystem → plugin-sharing effectiveSharingModel + security-posture lint - object enable.searchable → metadata-protocol global-search opt-out - action type:'form' → objectui ActionRunner.executeForm (/forms/:target) Deferred: object enable.trash/mru (inert default-true, ~35 sys-object setters). Verify: spec 6873 + DTS typecheck + liveness gate + check:docs green; lint 30, cli 13, metadata-protocol 46 green; api-surface unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011XchBKhKdjbhQZymt9Gn9B * ci: trigger checks for #3199 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011XchBKhKdjbhQZymt9Gn9B --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a3823b2 commit 5754a23

30 files changed

Lines changed: 123 additions & 476 deletions
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: remove form-surfaced dead metadata props + correct 3 misclassified-live entries (#2377, ADR-0049)
6+
7+
The next enforce-or-remove slice of #2377. Versioned `minor` per the launch-window
8+
policy (the fixed group makes a `major` promote the whole monorepo).
9+
10+
## Removed (dead, no runtime reader — verified in both framework and objectui)
11+
12+
- **field**: `columnName`, `index`, `referenceFilters`. This empties the field
13+
dead-prop set. `columnName` also removed its now-moot **ADR-0062 D7** lint
14+
(`validate-expressions.ts`), the dead `StorageNameMapping.resolveColumnName` /
15+
`buildColumnMap` / `buildReverseColumnMap` helpers, and closes ADR-0062 R10 —
16+
external physical-column mapping is `external.columnMap` only.
17+
- **object**: `tags`, `active`, `abstract` — now rejecting tombstones in
18+
`UNKNOWN_KEY_GUIDANCE`.
19+
- **agent**: `tenantId`.
20+
21+
The removed props are dropped from the authoring forms (`field/object/agent.form.ts`)
22+
and the regenerated metadata-forms i18n bundles.
23+
24+
## Corrected to `live` (the ledger was wrong — readers existed)
25+
26+
- **object `isSystem`**`plugin-sharing` `effectiveSharingModel` defaults a
27+
no-`sharingModel` `isSystem` object to public; also read by the security-posture
28+
lint. KEPT.
29+
- **object `enable.searchable`**`metadata-protocol` global search (`searchAll`)
30+
uses `enable.searchable === false` as an opt-out. KEPT.
31+
- **action `type:'form'`** — objectui `ActionRunner.executeForm` routes it to the
32+
FormView at `/forms/:target`; a build-time lint validates the target. KEPT.
33+
34+
## Deliberately deferred
35+
36+
`object.enable.trash` / `enable.mru` — dead, but inert `default(true)` flags set by
37+
~35 `sys-*.object.ts` files; removing them is high-churn / low-value. Left `dead`
38+
(authorWarn-skipped).
39+
40+
## Migration
41+
42+
- field/agent props: authoring them was already a no-op; they now strip silently.
43+
`columnName` → the physical column is always the field key (rename the field, or
44+
use `external.columnMap` for external objects); `index` → declare it in object
45+
`indexes[]`; `referenceFilters``lookupFilters`.
46+
- object `tags`/`active`/`abstract`: `ObjectSchema.create()` now throws a located
47+
error naming the removal. None gated anything at runtime — remove them.

CONTRIBUTING.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,6 @@ Use `camelCase`:
181181
```typescript
182182
{
183183
maxLength: 100,
184-
referenceFilters: ['active'],
185184
defaultValue: 'none',
186185
}
187186
```

content/docs/references/ai/agent.mdx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,6 @@ const result = AIKnowledge.parse(data);
8383
| **active** | `boolean` || |
8484
| **access** | `string[]` | optional | Who can chat with this agent |
8585
| **permissions** | `string[]` | optional | Required permission-set capabilities |
86-
| **tenantId** | `string` | optional | Tenant/Organization ID |
8786
| **visibility** | `Enum<'global' \| 'organization' \| 'private'>` || [EXPERIMENTAL — NOT ENFORCED, #1901] Intended listing scope. No runtime consumer yet; use access/permissions for real gating. |
8887
| **planning** | `{ maxIterations: integer }` | optional | Autonomous reasoning and planning configuration |
8988
| **memory** | `{ longTerm?: object; reflectionInterval?: integer }` | optional | Agent memory management |

content/docs/references/data/field.mdx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,6 @@ const result = Address.parse(data);
102102
| **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 |
103103
| **description** | `string` | optional | Tooltip/Help text |
104104
| **format** | `string` | optional | Format string (e.g. email, phone) |
105-
| **columnName** | `string` | optional | Physical column name in the target datasource. Defaults to the field key when not set. |
106105
| **required** | `boolean` | optional | Is required |
107106
| **searchable** | `boolean` | optional | Is searchable |
108107
| **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);
116115
| **max** | `number` | optional | Maximum value |
117116
| **options** | `{ label: string; value: string; color?: string; default?: boolean; … }[]` | optional | Static options for select/multiselect |
118117
| **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. |
119-
| **referenceFilters** | `string[]` | optional | Filters applied to lookup dialogs (e.g. "active = true") |
120118
| **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional | What happens if referenced record is deleted |
121119
| **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. |
122120
| **inlineTitle** | `string` | optional | Title for the inline master-detail grid |
@@ -129,7 +127,7 @@ const result = Address.parse(data);
129127
| **descriptionField** | `string` | optional | Secondary field shown under the label in the quick-select popover. |
130128
| **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. |
131129
| **lookupPageSize** | `integer` | optional | Rows per page in the record-picker dialog (default 10). |
132-
| **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. |
130+
| **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. |
133131
| **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. |
134132
| **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. |
135133
| **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);
153151
| **sortable** | `boolean` | optional | Whether field is sortable in list views |
154152
| **inlineHelpText** | `string` | optional | Help text displayed below the field in forms |
155153
| **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). |
156-
| **index** | `boolean` | optional | Create standard database index |
157154
| **externalId** | `boolean` | optional | Is external ID for upsert operations |
158155

159156

content/docs/references/data/object.mdx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,7 @@ const result = ApiMethod.parse(data);
100100
| **pluralLabel** | `string` | optional | Human readable plural label (e.g. "Accounts") |
101101
| **description** | `string` | optional | Developer documentation / description |
102102
| **icon** | `string` | optional | Icon name (Lucide/Material) for UI representation |
103-
| **tags** | `string[]` | optional | Categorization tags (e.g. "sales", "system", "reference") |
104-
| **active** | `boolean` | optional | Is the object active and usable |
105-
| **isSystem** | `boolean` | optional | Is system object (protected from deletion) |
106-
| **abstract** | `boolean` | optional | Is abstract base object (cannot be instantiated) |
103+
| **isSystem** | `boolean` | optional | Is system object (protected from deletion; defaults its org-wide sharing to public when no sharingModel is set — plugin-sharing) |
107104
| **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. |
108105
| **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. |
109106
| **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. |

docs/adr/0062-external-datasource-runtime.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ This ADR defines the **complete external-datasource runtime contract** and the d
3131
| R7 | Introspection (`remote-tables`, `object-draft`) + runtime Sync wizard | ✅ shipped | ADR-0015 addendum; `service-datasource` |
3232
| R8 | **Connection lifecycle/health/pooling for N datasources** |**gap** | only the single `default` driver's lifecycle is managed today |
3333
| R9 | dogfood/verify handles read-only external; canonical example | ✅ shipped | #2139 (`verify` skip + `app-showcase`) |
34-
| R10 | **`columnMap``field.columnName` single source of truth** | **gap** | two inverse mechanisms coexist |
34+
| 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 |
3535

3636
### The structural gap, precisely (R2)
3737

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

8484
`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.
8585

86+
> **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.
87+
8688
> **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.
8789
8890
### D8 — Drop the `onEnable` bridge from the canonical example; keep it as an escape hatch

packages/cli/src/utils/lint-liveness-properties.test.ts

Lines changed: 14 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,26 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4-
import {
5-
lintLivenessProperties,
6-
LIVENESS_DEAD_PROPERTY,
7-
} from './lint-liveness-properties.js';
4+
import { lintLivenessProperties } from './lint-liveness-properties.js';
85

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

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

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

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

45-
it('warns on a misleading dead field prop (columnName)', () => {
46-
const findings = lintLivenessProperties(
47-
objStack({ fields: [{ name: 'code', type: 'text', columnName: 'legacy_code' }] }),
48-
);
49-
const f = findings.find((x) => x.message.includes('columnName'));
50-
expect(f).toBeDefined();
51-
expect(f!.where).toBe("object 'widget' · field 'code'");
52-
});
53-
54-
it('warns on a field-level index flag set true, but not when false', () => {
55-
const on = lintLivenessProperties(objStack({ fields: [{ name: 'code', type: 'text', index: true }] }));
56-
expect(paths(on).some((m) => m.includes('`index`'))).toBe(true);
57-
const off = lintLivenessProperties(objStack({ fields: [{ name: 'code', type: 'text', index: false }] }));
58-
expect(paths(off).some((m) => m.includes('`index`'))).toBe(false);
59-
});
60-
6140
it('is silent for a clean object with only live properties', () => {
6241
const findings = lintLivenessProperties(
6342
objStack({
@@ -70,10 +49,13 @@ describe('lintLivenessProperties', () => {
7049
});
7150

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

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

0 commit comments

Comments
 (0)