Skip to content

Commit f985722

Browse files
os-zhuangclaude
andcommitted
feat(spec,lint): adaptive record surface + semantic field span (#2578)
Step 1 (framework) of #2578 — field-heavy objects, AI-authored: - deriveRecordSurface(def, opts): pure spec derivation of the default record surface (page vs drawer/modal) from authorable (visible, non-system) field count; mobile forces page. A DERIVATION, not an authored key — a `recordSurface` object key fails ADR-0085 §2's admission test (field count is machine-inferable, modal-vs-page is pure re-arrangement). - FormField.span: 'auto' | 'full' — relative field width decoupled from the per-surface derived column count (mobile 1 / modal 2 / page 3-4); replaces absolute colSpan as the primary primitive (colSpan retained + clamped by the renderer). `half` deferred. - validateFormLayout lint: form-field-unknown + absolute-colspan-discouraged (both warnings with fix hints, ADR-0078/0019, same bar for AI + hand authors). - ADR-0085 §2 rejected-keys clarification (recordSurface); changeset. Additive only (0 breaking). spec 6677 tests + api-surface green; lint 121 tests + tsc green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c214a72 commit f985722

10 files changed

Lines changed: 503 additions & 3 deletions
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/lint": minor
4+
---
5+
6+
feat(spec,lint): adaptive record surface + semantic field `span` for field-heavy objects (#2578)
7+
8+
Field-heavy objects need two things the protocol did not express well: multi-column
9+
forms, and opening create/edit/detail as a full page rather than a cramped popup —
10+
for *some* objects, automatically. Because all metadata is AI-authored, the design
11+
goal is to make AI unable to get it wrong, which reshaped both features away from
12+
new authored keys.
13+
14+
**`deriveRecordSurface` (new spec derivation, ADR-0085 §5).** A record's default
15+
surface — full `page` vs `drawer`/`modal` overlay — is *derived* from how heavy the
16+
record is (visible, non-system field count; mobile always pages), not authored. Per
17+
ADR-0085 §2's admission test a `recordSurface` object key would fail: field count is
18+
exactly the kind of fact a machine can infer, and modal-vs-page is pure
19+
re-arrangement, not a business fact. So there is **no new object key** and **no new
20+
ADR** — just a single shared derivation renderers consume as a default (an explicit
21+
form/navigation config still wins), plus a one-line clarification to ADR-0085 §2's
22+
rejected-keys list so `recordSurface` is not re-proposed. Explicit per-object control
23+
remains the sanctioned assigned-page path.
24+
25+
**`FormField.span: 'auto' | 'full'` (new, replaces absolute `colSpan` as the
26+
primary primitive).** Under a per-surface derived column count (mobile 1 / modal 2 /
27+
page 3-4) an absolute `colSpan: 3` only lines up at the one width the author
28+
imagined — fragile by construction. The relative `span` is decoupled from the column
29+
count: `auto` (default; omit it) sizes by widget type × current columns, `full` takes
30+
the whole row at any count. `colSpan` is retained for back-compat and clamped by the
31+
renderer; `half` was considered and deferred (weakest AI-safety). The rationale lives
32+
here rather than in a new ADR, per the fewer-ADRs convention.
33+
34+
**`validateFormLayout` (new lint, ADR-0078/0019).** Two advisory rules over authored
35+
form views: `form-field-unknown` (a section references a field not on the bound
36+
object — silently never renders) and `absolute-colspan-discouraged` (steers authors
37+
to `span: 'full'`). Both warnings, with fix hints, held to the same bar for AI and
38+
hand authors.
39+
40+
All additive: no exports removed, no behavior change for existing metadata.

‎docs/adr/0085-object-semantic-roles-over-surface-hint-blocks.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ A new presentation-intent key enters `ObjectSchema` only if **both** hold:
6565
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);
6666
2. it binds to the object **across surfaces** — if the honest name for the key contains a page name, it fails.
6767

68-
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.
68+
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.
6969

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

‎packages/lint/src/index.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,10 @@ export {
6161
SEMANTIC_ROLE_FIELD_UNKNOWN,
6262
} from './validate-semantic-roles.js';
6363
export type { SemanticRoleFinding, SemanticRoleSeverity } from './validate-semantic-roles.js';
64+
65+
export {
66+
validateFormLayout,
67+
FORM_FIELD_UNKNOWN,
68+
FORM_COLSPAN_ABSOLUTE,
69+
} from './validate-form-layout.js';
70+
export type { FormLayoutFinding, FormLayoutSeverity } from './validate-form-layout.js';
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import {
5+
validateFormLayout,
6+
FORM_FIELD_UNKNOWN,
7+
FORM_COLSPAN_ABSOLUTE,
8+
} from './validate-form-layout';
9+
10+
const objects = [
11+
{ name: 'contract', fields: { name: {}, amount: {}, status: {}, notes: {} } },
12+
];
13+
14+
describe('validateFormLayout (#2578)', () => {
15+
it('is clean for a well-formed multi-column form (known fields, no colSpan)', () => {
16+
const stack = {
17+
objects,
18+
views: [
19+
{
20+
name: 'contract_form',
21+
type: 'simple',
22+
data: { provider: 'object', object: 'contract' },
23+
sections: [
24+
{ label: 'Basics', columns: 2, fields: ['name', 'amount', { field: 'notes', span: 'full' }] },
25+
],
26+
},
27+
],
28+
};
29+
expect(validateFormLayout(stack)).toEqual([]);
30+
});
31+
32+
it('flags a section field that is not on the bound object', () => {
33+
const stack = {
34+
objects,
35+
views: [
36+
{
37+
name: 'contract_form',
38+
data: { provider: 'object', object: 'contract' },
39+
sections: [{ columns: 2, fields: ['name', 'ghost_field'] }],
40+
},
41+
],
42+
};
43+
const findings = validateFormLayout(stack);
44+
expect(findings).toHaveLength(1);
45+
expect(findings[0].rule).toBe(FORM_FIELD_UNKNOWN);
46+
expect(findings[0].severity).toBe('warning');
47+
expect(findings[0].message).toContain('ghost_field');
48+
expect(findings[0].path).toBe('views[0].sections[0].fields[1]');
49+
});
50+
51+
it('discourages absolute colSpan and steers to span', () => {
52+
const stack = {
53+
objects,
54+
views: [
55+
{
56+
name: 'contract_form',
57+
data: { provider: 'object', object: 'contract' },
58+
sections: [{ columns: 2, fields: ['name', { field: 'amount', colSpan: 2 }] }],
59+
},
60+
],
61+
};
62+
const findings = validateFormLayout(stack);
63+
expect(findings).toHaveLength(1);
64+
expect(findings[0].rule).toBe(FORM_COLSPAN_ABSOLUTE);
65+
expect(findings[0].hint).toContain("span: 'full'");
66+
expect(findings[0].path).toBe('views[0].sections[0].fields[1].colSpan');
67+
});
68+
69+
it('reports both rules for the same field independently', () => {
70+
const stack = {
71+
objects,
72+
views: [
73+
{
74+
name: 'contract_form',
75+
data: { provider: 'object', object: 'contract' },
76+
sections: [{ columns: 2, fields: [{ field: 'ghost', colSpan: 3 }] }],
77+
},
78+
],
79+
};
80+
const rules = validateFormLayout(stack).map(f => f.rule).sort();
81+
expect(rules).toEqual([FORM_COLSPAN_ABSOLUTE, FORM_FIELD_UNKNOWN].sort());
82+
});
83+
84+
it('skips reference-checking when the bound object cannot be resolved', () => {
85+
const stack = {
86+
objects,
87+
views: [
88+
{
89+
name: 'orphan_form',
90+
data: { provider: 'object', object: 'does_not_exist' },
91+
sections: [{ columns: 2, fields: ['whatever', { field: 'x', colSpan: 2 }] }],
92+
},
93+
],
94+
};
95+
const findings = validateFormLayout(stack);
96+
// No form-field-unknown (object unresolved), but colSpan is still flagged.
97+
expect(findings.map(f => f.rule)).toEqual([FORM_COLSPAN_ABSOLUTE]);
98+
});
99+
100+
it('ignores non-form views (no sections array)', () => {
101+
const stack = {
102+
objects,
103+
views: [
104+
{ name: 'grid', type: 'grid', data: { object: 'contract' }, columns: ['name', 'ghost'] },
105+
],
106+
};
107+
expect(validateFormLayout(stack)).toEqual([]);
108+
});
109+
110+
it('tolerates an empty / shapeless stack', () => {
111+
expect(validateFormLayout({})).toEqual([]);
112+
expect(validateFormLayout({ views: [], objects: [] })).toEqual([]);
113+
});
114+
});
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Build-time form-layout diagnostics (#2578).
5+
*
6+
* Authored form views carry field references and column-layout hints that are
7+
* Zod-valid but can be silently wrong at render time — the "parsed, unmarked,
8+
* silently inert" shape ADR-0078 prohibits. This lint catches the two that
9+
* matter for multi-column, AI-authored forms, uniformly for `os build` /
10+
* `os validate`, MCP authoring and hand authors (ADR-0019).
11+
*
12+
* Both rules are warnings, not errors — nothing is fully broken (an unknown
13+
* field name is skipped; an over-wide colSpan is clamped) — but each is almost
14+
* certainly an authoring mistake worth surfacing at author time:
15+
*
16+
* - `form-field-unknown` — a section references a field that is not on the
17+
* form's bound object, so the field silently does not render.
18+
* - `absolute-colspan-discouraged` — a field uses the absolute `colSpan`. Under
19+
* a per-surface DERIVED column count (mobile 1 / modal 2 / page 3-4) a fixed
20+
* span only lines up at the one width the author imagined; the renderer
21+
* clamps it. The robust primitive is the relative `span: 'full'`.
22+
*
23+
* Scope: top-level form `views` (a `sections` array). Forms embedded inside
24+
* page component trees are a follow-up — the walker deliberately stays shallow
25+
* so it never guesses at an arbitrary component's object binding.
26+
*/
27+
28+
export const FORM_FIELD_UNKNOWN = 'form-field-unknown';
29+
export const FORM_COLSPAN_ABSOLUTE = 'absolute-colspan-discouraged';
30+
31+
export type FormLayoutSeverity = 'error' | 'warning';
32+
33+
export interface FormLayoutFinding {
34+
/** Always `warning` today — both rules are advisory (see module note). */
35+
severity: FormLayoutSeverity;
36+
/** Diagnostic rule id, e.g. `form-field-unknown`. */
37+
rule: string;
38+
/** Human-readable location, e.g. `view "contract_form"`. */
39+
where: string;
40+
/** Config path, e.g. `views[2].sections[0].fields[3]`. */
41+
path: string;
42+
/** What is wrong. */
43+
message: string;
44+
/** How to fix it. */
45+
hint: string;
46+
}
47+
48+
type AnyRec = Record<string, unknown>;
49+
50+
/** Coerce a collection (array or name-keyed map) to an array of records. */
51+
function asArray(v: unknown): AnyRec[] {
52+
if (Array.isArray(v)) return v as AnyRec[];
53+
if (v && typeof v === 'object') {
54+
return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) }));
55+
}
56+
return [];
57+
}
58+
59+
/** A section field entry is either a bare field name or `{ field, colSpan, … }`. */
60+
function fieldNameOf(entry: unknown): string | null {
61+
if (typeof entry === 'string') return entry.length > 0 ? entry : null;
62+
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
63+
const f = (entry as AnyRec).field;
64+
return typeof f === 'string' && f.length > 0 ? f : null;
65+
}
66+
return null;
67+
}
68+
69+
/** The object a form view binds to: `data.object` (canonical) or `objectName`. */
70+
function boundObject(view: AnyRec): string | undefined {
71+
const data = view.data;
72+
if (data && typeof data === 'object' && typeof (data as AnyRec).object === 'string') {
73+
return (data as AnyRec).object as string;
74+
}
75+
return typeof view.objectName === 'string' ? (view.objectName as string) : undefined;
76+
}
77+
78+
/**
79+
* Validate authored form-view layout. Returns findings (empty = clean).
80+
* Advisory only — the caller must never fail the build on these alone.
81+
*/
82+
export function validateFormLayout(stack: AnyRec): FormLayoutFinding[] {
83+
const findings: FormLayoutFinding[] = [];
84+
85+
// object name → its field-name set, for reference checking.
86+
const objectFields = new Map<string, Set<string>>();
87+
for (const obj of asArray(stack.objects)) {
88+
const name = typeof obj.name === 'string' ? obj.name : undefined;
89+
if (!name) continue;
90+
const fields = (obj.fields && typeof obj.fields === 'object' && !Array.isArray(obj.fields))
91+
? Object.keys(obj.fields as AnyRec)
92+
: [];
93+
objectFields.set(name, new Set(fields));
94+
}
95+
96+
const views = asArray(stack.views);
97+
for (let i = 0; i < views.length; i++) {
98+
const view = views[i];
99+
if (!view || typeof view !== 'object') continue;
100+
const sections = Array.isArray(view.sections) ? view.sections : null;
101+
if (!sections) continue; // only form views carry a sections array
102+
103+
const viewName = typeof view.name === 'string' ? view.name : `(view ${i})`;
104+
const objName = boundObject(view);
105+
// Only reference-check when the bound object resolves; otherwise we can't.
106+
const known = objName ? objectFields.get(objName) : undefined;
107+
const where = `view "${viewName}"`;
108+
const base = `views[${i}]`;
109+
110+
for (let s = 0; s < sections.length; s++) {
111+
const sec = sections[s];
112+
const secFields = sec && typeof sec === 'object' && Array.isArray((sec as AnyRec).fields)
113+
? ((sec as AnyRec).fields as unknown[])
114+
: [];
115+
for (let f = 0; f < secFields.length; f++) {
116+
const entry = secFields[f];
117+
const fname = fieldNameOf(entry);
118+
const fpath = `${base}.sections[${s}].fields[${f}]`;
119+
120+
// ── (a) section field references a real field on the bound object ──
121+
if (fname && known && !known.has(fname)) {
122+
findings.push({
123+
severity: 'warning',
124+
rule: FORM_FIELD_UNKNOWN,
125+
where,
126+
path: fpath,
127+
message:
128+
`${viewName}: field "${fname}" is not a field on object "${objName}" — ` +
129+
`it is silently skipped and never renders on the form`,
130+
hint:
131+
`Fix the field name, or add "${fname}" to ${objName}. Section field ` +
132+
`references must match the object's field names exactly.`,
133+
});
134+
}
135+
136+
// ── (b) absolute colSpan → steer to the surface-independent span ──
137+
const colSpan = entry && typeof entry === 'object' && !Array.isArray(entry)
138+
? (entry as AnyRec).colSpan
139+
: undefined;
140+
if (colSpan != null) {
141+
findings.push({
142+
severity: 'warning',
143+
rule: FORM_COLSPAN_ABSOLUTE,
144+
where,
145+
path: `${fpath}.colSpan`,
146+
message:
147+
`${viewName}: field "${fname ?? '?'}" sets absolute colSpan ${String(colSpan)} — ` +
148+
`the form's column count is derived per surface (mobile 1 / modal 2 / page 3-4), ` +
149+
`so a fixed span only aligns at one width`,
150+
hint:
151+
`Prefer span: 'full' (whole row at any column count), or omit for auto ` +
152+
`width. The renderer clamps colSpan to the current column count.`,
153+
});
154+
}
155+
}
156+
}
157+
}
158+
159+
return findings;
160+
}

‎packages/spec/api-surface.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,11 +363,15 @@
363363
"QueryFilterSchema (const)",
364364
"QueryInput (type)",
365365
"QuerySchema (const)",
366+
"RECORD_SURFACE_PAGE_THRESHOLD (const)",
366367
"RangeOperatorSchema (const)",
367368
"Reaction (type)",
368369
"ReactionSchema (const)",
369370
"RecordSubscription (type)",
370371
"RecordSubscriptionSchema (const)",
372+
"RecordSurface (type)",
373+
"RecordSurfaceOptions (interface)",
374+
"RecordSurfaceViewport (type)",
371375
"ReferenceResolution (type)",
372376
"ReferenceResolutionError (type)",
373377
"ReferenceResolutionErrorSchema (const)",
@@ -450,13 +454,15 @@
450454
"WindowSpec (type)",
451455
"WindowSpecSchema (const)",
452456
"canonicalizeSqlType (function)",
457+
"countAuthorableFields (function)",
453458
"defaultAggregateFor (function)",
454459
"defineCube (function)",
455460
"defineDatasource (function)",
456461
"defineMapping (function)",
457462
"defineObjectExtension (function)",
458463
"defineSeed (function)",
459464
"deriveFieldGroupLayout (function)",
465+
"deriveRecordSurface (function)",
460466
"fieldForm (const)",
461467
"hasDynamicTokens (function)",
462468
"hookForm (const)",

‎packages/spec/src/data/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ export * from './display-name';
5353
// grouping semantics every renderer (form, detail, drawer, designer) applies.
5454
export * from './field-group-layout';
5555

56+
// record-surface derivation (ADR-0085 §5) — the single source for how a record's
57+
// create/edit/detail opens by default (full page vs drawer/modal overlay).
58+
export * from './record-surface';
59+
5660
// Feed & Activity Protocol
5761
export * from './feed.zod';
5862

0 commit comments

Comments
 (0)