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/adaptive-record-surface-span-2578.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
"@objectstack/spec": minor
"@objectstack/lint": minor
---

feat(spec,lint): adaptive record surface + semantic field `span` for field-heavy objects (#2578)

Field-heavy objects need two things the protocol did not express well: multi-column
forms, and opening create/edit/detail as a full page rather than a cramped popup —
for *some* objects, automatically. Because all metadata is AI-authored, the design
goal is to make AI unable to get it wrong, which reshaped both features away from
new authored keys.

**`deriveRecordSurface` (new spec derivation, ADR-0085 §5).** A record's default
surface — full `page` vs `drawer`/`modal` overlay — is *derived* from how heavy the
record is (visible, non-system field count; mobile always pages), not authored. Per
ADR-0085 §2's admission test a `recordSurface` object key would fail: field count is
exactly the kind of fact a machine can infer, and modal-vs-page is pure
re-arrangement, not a business fact. So there is **no new object key** and **no new
ADR** — just a single shared derivation renderers consume as a default (an explicit
form/navigation config still wins), plus a one-line clarification to ADR-0085 §2's
rejected-keys list so `recordSurface` is not re-proposed. Explicit per-object control
remains the sanctioned assigned-page path.

**`FormField.span: 'auto' | 'full'` (new, replaces absolute `colSpan` as the
primary primitive).** Under a per-surface derived column count (mobile 1 / modal 2 /
page 3-4) an absolute `colSpan: 3` only lines up at the one width the author
imagined — fragile by construction. The relative `span` is decoupled from the column
count: `auto` (default; omit it) sizes by widget type × current columns, `full` takes
the whole row at any count. `colSpan` is retained for back-compat and clamped by the
renderer; `half` was considered and deferred (weakest AI-safety). The rationale lives
here rather than in a new ADR, per the fewer-ADRs convention.

**`validateFormLayout` (new lint, ADR-0078/0019).** Two advisory rules over authored
form views: `form-field-unknown` (a section references a field not on the bound
object — silently never renders) and `absolute-colspan-discouraged` (steers authors
to `span: 'full'`). Both warnings, with fix hints, held to the same bar for AI and
hand authors.

**`NavigationConfig.size` (new) replaces pixel `width`.** A T-shirt bucket
(`auto`/sm/md/lg/xl/full, default `auto`, aligned with `FormView.modalSize`) for a
drawer/modal detail overlay. `width`/`drawerWidth` (pixel) are deprecated: a pixel
width cannot be authored blind — the author (often an AI) does not know the client
viewport. `auto` means the renderer derives the size from field count and clamps to
the viewport, so AI writes nothing.

All additive: no exports removed, no behavior change for existing metadata.
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ A new presentation-intent key enters `ObjectSchema` only if **both** hold:
1. it injects business intent **not inferable** from the schema (the machine cannot know whether `status` is ordinal, or which four fields this business watches);
2. it binds to the object **across surfaces** — if the honest name for the key contains a page name, it fails.

Keys that merely hide/re-arrange correct information (`hideRelatedTab`, `showReferenceRail`, `relatedLayout`, `useFieldGroups`, explicit `detail.sections`) are rejected at this layer. The supported path for per-page control is the one that already exists and is honestly scoped: an **assigned page** (full page schema). If a genuine relationship-level need appears (e.g. "this child object is noise on every parent"), it gets modeled at the relationship layer, not as a page toggle.
Keys that merely hide/re-arrange correct information (`hideRelatedTab`, `showReferenceRail`, `relatedLayout`, `useFieldGroups`, explicit `detail.sections`, or a **modal-vs-page presentation surface** such as a `recordSurface` key) are rejected at this layer. The supported path for per-page control is the one that already exists and is honestly scoped: an **assigned page** (full page schema). A record's *default* surface (full page vs. drawer/modal overlay) is not authored at all: it is **derived** from how heavy the record is — `deriveRecordSurface` (#2578) — because field count is exactly the kind of fact a machine can infer, so a `recordSurface` key fails admission test #1. If a genuine relationship-level need appears (e.g. "this child object is noise on every parent"), it gets modeled at the relationship layer, not as a page toggle.

### 3. Deletions (all evidenced zero-author)

Expand Down
7 changes: 7 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,10 @@ export {
SEMANTIC_ROLE_FIELD_UNKNOWN,
} from './validate-semantic-roles.js';
export type { SemanticRoleFinding, SemanticRoleSeverity } from './validate-semantic-roles.js';

export {
validateFormLayout,
FORM_FIELD_UNKNOWN,
FORM_COLSPAN_ABSOLUTE,
} from './validate-form-layout.js';
export type { FormLayoutFinding, FormLayoutSeverity } from './validate-form-layout.js';
114 changes: 114 additions & 0 deletions packages/lint/src/validate-form-layout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import {
validateFormLayout,
FORM_FIELD_UNKNOWN,
FORM_COLSPAN_ABSOLUTE,
} from './validate-form-layout';

const objects = [
{ name: 'contract', fields: { name: {}, amount: {}, status: {}, notes: {} } },
];

describe('validateFormLayout (#2578)', () => {
it('is clean for a well-formed multi-column form (known fields, no colSpan)', () => {
const stack = {
objects,
views: [
{
name: 'contract_form',
type: 'simple',
data: { provider: 'object', object: 'contract' },
sections: [
{ label: 'Basics', columns: 2, fields: ['name', 'amount', { field: 'notes', span: 'full' }] },
],
},
],
};
expect(validateFormLayout(stack)).toEqual([]);
});

it('flags a section field that is not on the bound object', () => {
const stack = {
objects,
views: [
{
name: 'contract_form',
data: { provider: 'object', object: 'contract' },
sections: [{ columns: 2, fields: ['name', 'ghost_field'] }],
},
],
};
const findings = validateFormLayout(stack);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(FORM_FIELD_UNKNOWN);
expect(findings[0].severity).toBe('warning');
expect(findings[0].message).toContain('ghost_field');
expect(findings[0].path).toBe('views[0].sections[0].fields[1]');
});

it('discourages absolute colSpan and steers to span', () => {
const stack = {
objects,
views: [
{
name: 'contract_form',
data: { provider: 'object', object: 'contract' },
sections: [{ columns: 2, fields: ['name', { field: 'amount', colSpan: 2 }] }],
},
],
};
const findings = validateFormLayout(stack);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(FORM_COLSPAN_ABSOLUTE);
expect(findings[0].hint).toContain("span: 'full'");
expect(findings[0].path).toBe('views[0].sections[0].fields[1].colSpan');
});

it('reports both rules for the same field independently', () => {
const stack = {
objects,
views: [
{
name: 'contract_form',
data: { provider: 'object', object: 'contract' },
sections: [{ columns: 2, fields: [{ field: 'ghost', colSpan: 3 }] }],
},
],
};
const rules = validateFormLayout(stack).map(f => f.rule).sort();
expect(rules).toEqual([FORM_COLSPAN_ABSOLUTE, FORM_FIELD_UNKNOWN].sort());
});

it('skips reference-checking when the bound object cannot be resolved', () => {
const stack = {
objects,
views: [
{
name: 'orphan_form',
data: { provider: 'object', object: 'does_not_exist' },
sections: [{ columns: 2, fields: ['whatever', { field: 'x', colSpan: 2 }] }],
},
],
};
const findings = validateFormLayout(stack);
// No form-field-unknown (object unresolved), but colSpan is still flagged.
expect(findings.map(f => f.rule)).toEqual([FORM_COLSPAN_ABSOLUTE]);
});

it('ignores non-form views (no sections array)', () => {
const stack = {
objects,
views: [
{ name: 'grid', type: 'grid', data: { object: 'contract' }, columns: ['name', 'ghost'] },
],
};
expect(validateFormLayout(stack)).toEqual([]);
});

it('tolerates an empty / shapeless stack', () => {
expect(validateFormLayout({})).toEqual([]);
expect(validateFormLayout({ views: [], objects: [] })).toEqual([]);
});
});
160 changes: 160 additions & 0 deletions packages/lint/src/validate-form-layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Build-time form-layout diagnostics (#2578).
*
* Authored form views carry field references and column-layout hints that are
* Zod-valid but can be silently wrong at render time — the "parsed, unmarked,
* silently inert" shape ADR-0078 prohibits. This lint catches the two that
* matter for multi-column, AI-authored forms, uniformly for `os build` /
* `os validate`, MCP authoring and hand authors (ADR-0019).
*
* Both rules are warnings, not errors — nothing is fully broken (an unknown
* field name is skipped; an over-wide colSpan is clamped) — but each is almost
* certainly an authoring mistake worth surfacing at author time:
*
* - `form-field-unknown` — a section references a field that is not on the
* form's bound object, so the field silently does not render.
* - `absolute-colspan-discouraged` — a field uses the absolute `colSpan`. Under
* a per-surface DERIVED column count (mobile 1 / modal 2 / page 3-4) a fixed
* span only lines up at the one width the author imagined; the renderer
* clamps it. The robust primitive is the relative `span: 'full'`.
*
* Scope: top-level form `views` (a `sections` array). Forms embedded inside
* page component trees are a follow-up — the walker deliberately stays shallow
* so it never guesses at an arbitrary component's object binding.
*/

export const FORM_FIELD_UNKNOWN = 'form-field-unknown';
export const FORM_COLSPAN_ABSOLUTE = 'absolute-colspan-discouraged';

export type FormLayoutSeverity = 'error' | 'warning';

export interface FormLayoutFinding {
/** Always `warning` today — both rules are advisory (see module note). */
severity: FormLayoutSeverity;
/** Diagnostic rule id, e.g. `form-field-unknown`. */
rule: string;
/** Human-readable location, e.g. `view "contract_form"`. */
where: string;
/** Config path, e.g. `views[2].sections[0].fields[3]`. */
path: string;
/** What is wrong. */
message: string;
/** How to fix it. */
hint: string;
}

type AnyRec = Record<string, unknown>;

/** Coerce a collection (array or name-keyed map) to an array of records. */
function asArray(v: unknown): AnyRec[] {
if (Array.isArray(v)) return v as AnyRec[];
if (v && typeof v === 'object') {
return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) }));
}
return [];
}

/** A section field entry is either a bare field name or `{ field, colSpan, … }`. */
function fieldNameOf(entry: unknown): string | null {
if (typeof entry === 'string') return entry.length > 0 ? entry : null;
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
const f = (entry as AnyRec).field;
return typeof f === 'string' && f.length > 0 ? f : null;
}
return null;
}

/** The object a form view binds to: `data.object` (canonical) or `objectName`. */
function boundObject(view: AnyRec): string | undefined {
const data = view.data;
if (data && typeof data === 'object' && typeof (data as AnyRec).object === 'string') {
return (data as AnyRec).object as string;
}
return typeof view.objectName === 'string' ? (view.objectName as string) : undefined;
}

/**
* Validate authored form-view layout. Returns findings (empty = clean).
* Advisory only — the caller must never fail the build on these alone.
*/
export function validateFormLayout(stack: AnyRec): FormLayoutFinding[] {
const findings: FormLayoutFinding[] = [];

// object name → its field-name set, for reference checking.
const objectFields = new Map<string, Set<string>>();
for (const obj of asArray(stack.objects)) {
const name = typeof obj.name === 'string' ? obj.name : undefined;
if (!name) continue;
const fields = (obj.fields && typeof obj.fields === 'object' && !Array.isArray(obj.fields))
? Object.keys(obj.fields as AnyRec)
: [];
objectFields.set(name, new Set(fields));
}

const views = asArray(stack.views);
for (let i = 0; i < views.length; i++) {
const view = views[i];
if (!view || typeof view !== 'object') continue;
const sections = Array.isArray(view.sections) ? view.sections : null;
if (!sections) continue; // only form views carry a sections array

const viewName = typeof view.name === 'string' ? view.name : `(view ${i})`;
const objName = boundObject(view);
// Only reference-check when the bound object resolves; otherwise we can't.
const known = objName ? objectFields.get(objName) : undefined;
const where = `view "${viewName}"`;
const base = `views[${i}]`;

for (let s = 0; s < sections.length; s++) {
const sec = sections[s];
const secFields = sec && typeof sec === 'object' && Array.isArray((sec as AnyRec).fields)
? ((sec as AnyRec).fields as unknown[])
: [];
for (let f = 0; f < secFields.length; f++) {
const entry = secFields[f];
const fname = fieldNameOf(entry);
const fpath = `${base}.sections[${s}].fields[${f}]`;

// ── (a) section field references a real field on the bound object ──
if (fname && known && !known.has(fname)) {
findings.push({
severity: 'warning',
rule: FORM_FIELD_UNKNOWN,
where,
path: fpath,
message:
`${viewName}: field "${fname}" is not a field on object "${objName}" — ` +
`it is silently skipped and never renders on the form`,
hint:
`Fix the field name, or add "${fname}" to ${objName}. Section field ` +
`references must match the object's field names exactly.`,
});
}

// ── (b) absolute colSpan → steer to the surface-independent span ──
const colSpan = entry && typeof entry === 'object' && !Array.isArray(entry)
? (entry as AnyRec).colSpan
: undefined;
if (colSpan != null) {
findings.push({
severity: 'warning',
rule: FORM_COLSPAN_ABSOLUTE,
where,
path: `${fpath}.colSpan`,
message:
`${viewName}: field "${fname ?? '?'}" sets absolute colSpan ${String(colSpan)} — ` +
`the form's column count is derived per surface (mobile 1 / modal 2 / page 3-4), ` +
`so a fixed span only aligns at one width`,
hint:
`Prefer span: 'full' (whole row at any column count), or omit for auto ` +
`width. The renderer clamps colSpan to the current column count.`,
});
}
}
}
}

return findings;
}
6 changes: 6 additions & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -363,11 +363,15 @@
"QueryFilterSchema (const)",
"QueryInput (type)",
"QuerySchema (const)",
"RECORD_SURFACE_PAGE_THRESHOLD (const)",
"RangeOperatorSchema (const)",
"Reaction (type)",
"ReactionSchema (const)",
"RecordSubscription (type)",
"RecordSubscriptionSchema (const)",
"RecordSurface (type)",
"RecordSurfaceOptions (interface)",
"RecordSurfaceViewport (type)",
"ReferenceResolution (type)",
"ReferenceResolutionError (type)",
"ReferenceResolutionErrorSchema (const)",
Expand Down Expand Up @@ -450,13 +454,15 @@
"WindowSpec (type)",
"WindowSpecSchema (const)",
"canonicalizeSqlType (function)",
"countAuthorableFields (function)",
"defaultAggregateFor (function)",
"defineCube (function)",
"defineDatasource (function)",
"defineMapping (function)",
"defineObjectExtension (function)",
"defineSeed (function)",
"deriveFieldGroupLayout (function)",
"deriveRecordSurface (function)",
"fieldForm (const)",
"hasDynamicTokens (function)",
"hookForm (const)",
Expand Down
4 changes: 4 additions & 0 deletions packages/spec/src/data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ export * from './display-name';
// grouping semantics every renderer (form, detail, drawer, designer) applies.
export * from './field-group-layout';

// record-surface derivation (ADR-0085 §5) — the single source for how a record's
// create/edit/detail opens by default (full page vs drawer/modal overlay).
export * from './record-surface';

// Feed & Activity Protocol
export * from './feed.zod';

Expand Down
Loading