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
30 changes: 30 additions & 0 deletions .changeset/adr-0078-completeness-gate.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
---
'@objectstack/spec': minor
'@objectstack/lint': minor
'@objectstack/cli': minor
---

The ADR-0078 completeness gate ships: a Zod-valid metadata instance that silently does nothing now fails at author time, on every authoring surface.

This closes the hole *between* the platform's existing gates. An instance can be Zod-valid (gate 1 green), use only *live* properties (gate 2 green), and a correctly-authored sibling can be proven to run (gate 3 green) — and still be dead, because it omits a config its consumer needs and the consumer silently no-ops. The founding case (cloud#687): an AI authored `{ type: 'summary' }` with no `summaryOperations`; the engine's index builder skips it, the field reads 0 forever, the dependent "occupancy rate" is stuck at 0 — and the agent reported the work done, because every gate it could see was green.

**Why this is worse than the unknown-key hole #4001 just closed.** There, the author wrote a key we don't know, and the parse now rejects it with a prescription. Here every key is one we know, the schema is satisfied, nothing warns, and the author gets a success. It manufactures false completion without the author mistyping anything — and the review step that catches a human's bare summary (seeing the field render `0`) is exactly the step AI authoring removes.

**One shared predicate, every surface — the ADR's core decision.** Instance-completeness checks previously existed *only* in cloud's AI-build graph-lint, so a stack authored with `os` + a coding assistant, an MCP agent, `os validate` in CI, or by hand got none of them (`formula_without_expression` existed nowhere in the framework). The judgement now lives in `@objectstack/spec/kernel`'s `checkFieldCompleteness` / `checkViewCompleteness` — sibling of `isIncoherentAggregate`, the ADR-0019 pattern — consumed by the new `@objectstack/lint` `validate-functional-completeness` and registered as an author-time rule (28 → 29), so `os build` / `os validate` / `os lint` / MCP / hand authoring are all covered. Cloud graph-lint can re-home its duplicate rules onto the same predicate rather than drifting from it.

**Every rule cites the runtime line that makes it true**, because the completeness audit's scariest candidate — a "sharing rule fails open and shares every record" — collapsed on a three-file read, and #4001's last two batches shipped four confidently wrong prescriptions before learning the same thing:

| rule | the silent skip | severity |
|---|---|---|
| `field/summary-without-operations` | `engine.ts` — `if (!d.summaryOperations) continue` | error |
| `field/formula-without-expression` | `engine.ts` builds the formula plan only from fields that HAVE one | error |
| `field/relationship-without-reference` | `$expand` — `if (!referenceObject) continue` | error |
| `field/choice-without-options` (`select`, `radio`) | `record-validator.ts` — an empty option list disables server-side value validation | error |
| `field/choice-without-options` (`checkboxes`) | same branch, but shared with free-form | warning |
| `view/layout-without-binding` (`kanban`, `calendar`, `gantt`) | renderer falls back to literal default field names | warning |

**The deliberate NON-rules are pinned as hard as the rules.** `multiselect` without options is *not* flagged: `record-validator.ts` says verbatim `// free-form (tags without options)`. The runtime blesses it as a mode, which makes it ADR-0078 case (3) "genuinely optional" — flagging it would be another false prescription, and the test is where that attempt fails first. `timeline` / `tree` views are likewise out of v1: they have config schemas, but their renderer behaviour has not had its verification pass.

**It found a real one on its first run against a real app.** `showcase_field_zoo.f_summary` was a bare `Field.summary({ label: 'Roll-up Summary' })` — one line below an `f_formula` that *is* complete, in the object whose entire job is to show what each field type looks like. So the canonical example of a roll-up in this repo computed nothing. It could not be fixed by adding `summaryOperations`: a roll-up aggregates a child into its parent, and the zoo is a leaf (`f_master_detail` makes it a child of `showcase_project`, and nothing is a child of the zoo). Removed, with the working examples named — `showcase_invoice.total` for the plain sum, `showcase_expense_report.total_amount` / `approved_amount` for the `summaryOperations.filter` variant. The rule it broke was the file's own: "relationship types point at the other showcase objects so they have REAL targets."

Tracked in #4544. This is Phase 1; Phase 2 (the cloud authoring-path config-drop fix) is in the `cloud` repo, and Phase 3 lands the Tier-B shapes one verification pass at a time.
21 changes: 20 additions & 1 deletion examples/app-showcase/src/data/objects/field-zoo.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,7 +131,26 @@ export const FieldZoo = ObjectSchema.create({
label: 'Formula (number × percent)',
expression: cel`(record.f_number == null ? 0 : record.f_number) * (record.f_percent == null ? 0 : record.f_percent) / 100`,
}),
f_summary: Field.summary({ label: 'Roll-up Summary' }),
// NO `summary` field here, deliberately — it is the one type this zoo
// cannot demonstrate. A roll-up aggregates a CHILD object into its parent,
// and the zoo is a leaf: `f_master_detail` below makes it a child of
// `showcase_project`, and nothing is a child of the zoo. A `Field.summary`
// with no `summaryOperations` is not a demo of the type — the engine's
// summary index skips it, so it reads 0 forever.
//
// It sat here as exactly that until the ADR-0078 completeness gate flagged
// it on its first run against a real app (#4544). Worth noting where it
// was: in the object whose whole job is to show what each field type looks
// like, one line below an `f_formula` that IS complete. The canonical
// example of a roll-up in this repo computed nothing — and the rule it
// broke was this file's own: "relationship types point at the other
// showcase objects so they have REAL targets".
//
// `summary` stays covered stack-wide (`collectFieldTypes` walks every
// object): `showcase_invoice.total` is the plain sum, and
// `showcase_expense_report.total_amount` / `approved_amount` show the
// `summaryOperations.filter` variant that rolls ONE child object into two
// different totals.
f_autonumber: Field.autonumber({ label: 'Auto Number' }),

// ── Embedded structured values (stored as JSON on the row) ───────────
Expand Down
19 changes: 19 additions & 0 deletions packages/cli/src/lint/authoring-rules.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,6 +75,7 @@
import {
validateStackExpressions,
validateListViewMode,
validateFunctionalCompleteness,
validateViewContainers,
validateWidgetBindings,
validateDashboardActionRefs,
Expand DownExpand Up@@ -229,6 +230,24 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
source: 'packages/lint/src/validate-list-view-mode.ts',
run: (stack) => validateListViewMode(stack),
},
// [ADR-0078] A Zod-VALID instance that silently does nothing: a `summary`
// with no `summaryOperations`, a `lookup` with no `reference`, a `select`
// with no `options`. Every key is one we know, so #4001's unknown-key
// rejection cannot see it, and the liveness ledger cannot either (it is
// per-property; the properties ARE live). This is the gate between them.
//
// `gating` because the error-severity shapes are fully inert — the field
// reads 0 forever while authoring reports success, which is the failure the
// ADR was written for (cloud#687). Pre-parse so the findings survive an
// unrelated schema error elsewhere in the stack.
{
name: 'validateFunctionalCompleteness',
tier: 'gating',
input: 'normalized',
commands: ALL,
source: 'packages/lint/src/validate-functional-completeness.ts',
run: (stack) => validateFunctionalCompleteness(stack),
},
// A flat list-view object in `views: []` parses to an EMPTY container
// (ViewSchema strips unknown keys): the schema step passes, zero views
// register, and the Console renders nothing. Pre-parse for the same reason.
Expand Down
10 changes: 10 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,16 @@ export { validateStackExpressions } from './validate-expressions.js';
export type { ExprIssue } from './validate-expressions.js';

export { validateListViewMode, LIST_VIEW_FILTERS_IN_VIEWS_MODE } from './validate-list-view-mode.js';

// [ADR-0078] The functional-completeness gate. All judgement lives in the shared
// predicate in `@objectstack/spec/kernel` (sibling of `isIncoherentAggregate`),
// so cloud graph-lint can re-home its duplicate rules onto the same source and
// the AI-build path cannot drift from the framework.
export { validateFunctionalCompleteness } from './validate-functional-completeness.js';
export type {
FunctionalCompletenessFinding,
FunctionalCompletenessSeverity,
} from './validate-functional-completeness.js';
export type { ListViewModeFinding, ListViewModeSeverity } from './validate-list-view-mode.js';
export {
validateFlowTriggerReadiness,
Expand Down
114 changes: 114 additions & 0 deletions packages/lint/src/validate-functional-completeness.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Tests for the ADR-0078 completeness validator.
*
* The predicate's own rules are proven in
* `@objectstack/spec`'s `functional-completeness.test.ts`; this file proves the
* WALK — that the rules reach every place a field or list view can be authored,
* in both collection spellings, with a usable location.
*
* That split matters here more than usual. This campaign's recurring finding is
* instruments that report coverage they do not have, and a completeness gate
* that walks half the stack is exactly that: green, and blind to the other half.
*/

import { describe, expect, it } from 'vitest';

import { validateFunctionalCompleteness } from './validate-functional-completeness.js';

const bareSummary = { type: 'summary' };

describe('validateFunctionalCompleteness — the walk', () => {
it('finds an inert field when objects and fields are ARRAYS', () => {
const findings = validateFunctionalCompleteness({
objects: [{ name: 'order', fields: [{ name: 'total', ...bareSummary }] }],
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe('field/summary-without-operations');
expect(findings[0].where).toBe('object "order" › fields.total');
expect(findings[0].path).toBe('objects[0].fields[0].summaryOperations');
});

it('finds the same field when objects and fields are name-keyed MAPS', () => {
// Both spellings are authorable, and a walk that handles only one is the
// half-blind instrument this suite exists to prevent.
const findings = validateFunctionalCompleteness({
objects: { order: { fields: { total: bareSummary } } },
});
expect(findings).toHaveLength(1);
expect(findings[0].where).toBe('object "order" › fields.total');
expect(findings[0].path).toBe('objects[0].fields.total.summaryOperations');
});

it('carries the fix through as the hint', () => {
const [f] = validateFunctionalCompleteness({
objects: [{ name: 'o', fields: [{ name: 'rel', type: 'lookup' }] }],
});
expect(f.hint).toContain('reference');
expect(f.severity).toBe('error');
});

it('reports every inert field, not just the first', () => {
const findings = validateFunctionalCompleteness({
objects: [{
name: 'order',
fields: [
{ name: 'total', type: 'summary' },
{ name: 'rate', type: 'formula' },
{ name: 'acct', type: 'lookup' },
{ name: 'stage', type: 'select' },
{ name: 'ok', type: 'text' },
],
}],
});
expect(findings.map((f) => f.rule).sort()).toEqual([
'field/choice-without-options',
'field/formula-without-expression',
'field/relationship-without-reference',
'field/summary-without-operations',
]);
});

it('walks list views in a container — both `list` and named `listViews`', () => {
const findings = validateFunctionalCompleteness({
views: [{
object: 'task',
list: { type: 'kanban' },
listViews: { by_month: { type: 'calendar' } },
}],
});
expect(findings.map((f) => f.path).sort()).toEqual([
'views[0].list.kanban',
'views[0].listViews.by_month.calendar',
]);
expect(findings.every((f) => f.severity === 'warning')).toBe(true);
});

it('is silent on a complete stack', () => {
expect(validateFunctionalCompleteness({
objects: [{
name: 'order',
fields: [
{ name: 'total', type: 'summary', summaryOperations: { object: 'line', field: 'amt', function: 'sum' } },
{ name: 'acct', type: 'lookup', reference: 'account' },
{ name: 'stage', type: 'select', options: [{ label: 'New', value: 'new' }] },
{ name: 'tags', type: 'multiselect' },
],
}],
views: [{ object: 'order', list: { type: 'grid' } }],
})).toEqual([]);
});

it('never throws on junk or partial stacks', () => {
for (const junk of [
undefined, null, 42, 'x', [], {},
{ objects: 'nope' }, { objects: [null, 7] },
{ objects: [{ name: 'o', fields: 'nope' }] },
{ views: [{ list: null }] },
{ views: 'nope' },
]) {
expect(() => validateFunctionalCompleteness(junk)).not.toThrow();
}
});
});
121 changes: 121 additions & 0 deletions packages/lint/src/validate-functional-completeness.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// [ADR-0078 Phase 1] The functional-completeness gate — `validate-functional-
// completeness`, the validator the ADR names and, until now, the one it said
// did not exist.
//
// A pure `(stack) => Finding[]` rule (ADR-0019). All judgement lives in the
// SHARED predicate — `@objectstack/spec/kernel`'s `checkFieldCompleteness` /
// `checkViewCompleteness`, the sibling of `isIncoherentAggregate` that cloud
// graph-lint is meant to re-home onto — so this file is only the walk: where
// fields and list views live in a stack, and how a predicate finding becomes a
// lint finding with a location. If a rule seems wrong, fix the predicate (and
// its runtime citation), never this walk.
//
// Why this closes a real hole: instance-completeness checks existed only in
// cloud's AI-build graph-lint, so a stack authored via `os` + a coding
// assistant, an MCP agent, `os validate` in CI, or a hand author got NONE of
// them (`formula_without_expression` existed nowhere in the framework). One
// predicate, every surface — the ADR's core decision.
//
// Runs on the NORMALIZED (pre-parse) stack like validate-list-view-mode: the
// findings must reach the author even when an unrelated schema error would
// stop the parse, and nothing here depends on parse-time defaults.

import {
checkFieldCompleteness,
checkViewCompleteness,
type CompletenessFinding,
} from '@objectstack/spec/kernel';

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

export interface FunctionalCompletenessFinding {
severity: FunctionalCompletenessSeverity;
/** Stable rule id from the shared predicate (e.g. `field/summary-without-operations`). */
rule: string;
/** Human-readable location, e.g. `object "order" › fields.total`. */
where: string;
/** Config path, e.g. `objects[2].fields.total.summaryOperations`. */
path: string;
message: string;
hint: string;
}

type AnyRec = Record<string, unknown>;

const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v);

/** Array-or-name-keyed-map collection → entries with a name and an index label. */
function entriesOf(v: unknown): Array<{ name: string; def: AnyRec; key: string }> {
if (Array.isArray(v)) {
return v.flatMap((def, i) =>
isRec(def) ? [{ name: String(def.name ?? i), def, key: `[${i}]` }] : [],
);
}
if (isRec(v)) {
return Object.entries(v).flatMap(([name, def]) =>
isRec(def) ? [{ name, def: { name, ...def }, key: `.${name}` }] : [],
);
}
return [];
}

function push(
out: FunctionalCompletenessFinding[],
found: CompletenessFinding[],
where: string,
basePath: string,
): void {
for (const f of found) {
out.push({
severity: f.severity,
rule: f.rule,
where,
path: `${basePath}.${f.path}`,
message: f.message,
hint: f.fix,
});
}
}

/**
* Walk every field definition and every list-view definition in the stack
* through the shared completeness predicate.
*/
export function validateFunctionalCompleteness(stack: unknown): FunctionalCompletenessFinding[] {
const out: FunctionalCompletenessFinding[] = [];
if (!isRec(stack)) return out;

// ── Fields: objects[].fields (map or array) ─────────────────────────────
for (const [oi, obj] of entriesOf(stack.objects).entries()) {
for (const field of entriesOf(obj.def.fields)) {
push(
out,
checkFieldCompleteness(field.def),
`object "${obj.name}" › fields.${field.name}`,
`objects[${oi}].fields${field.key}`,
);
}
}

// ── List views: views[] containers → list / listViews.* ────────────────
// (Form views carry no layout-binding contract; field completeness inside
// objects is already covered above.)
for (const [vi, container] of entriesOf(stack.views).entries()) {
const where = container.def.object ? `view container "${container.name}"` : `view container [${vi}]`;
if (isRec(container.def.list)) {
push(out, checkViewCompleteness(container.def.list), `${where} › list`, `views[${vi}].list`);
}
for (const lv of entriesOf(container.def.listViews)) {
push(
out,
checkViewCompleteness(lv.def),
`${where} › listViews.${lv.name}`,
`views[${vi}].listViews${lv.key}`,
);
}
}

return out;
}
9 changes: 9 additions & 0 deletions packages/spec/api-surface.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -1488,6 +1488,7 @@
"CompatibilityLevelSchema (const)",
"CompatibilityMatrixEntry (type)",
"CompatibilityMatrixEntrySchema (const)",
"CompletenessFinding (interface)",
"CustomizationOrigin (type)",
"CustomizationOriginSchema (const)",
"CustomizationPolicy (type)",
Expand DownExpand Up@@ -1565,6 +1566,11 @@
"ExecutionContextSchema (const)",
"ExtensionPoint (type)",
"ExtensionPointSchema (const)",
"FIELD_CHOICE_WITHOUT_OPTIONS (const)",
"FIELD_FORMULA_WITHOUT_EXPRESSION (const)",
"FIELD_RELATIONSHIP_WITHOUT_REFERENCE (const)",
"FIELD_SUMMARY_WITHOUT_OPERATIONS (const)",
"FUNCTIONAL_COMPLETENESS_RULES (const)",
"FieldChange (type)",
"FieldChangeSchema (const)",
"GetPackageRequest (type)",
Expand DownExpand Up@@ -1876,6 +1882,7 @@
"UpgradePlanSchema (const)",
"UpgradeSnapshot (type)",
"UpgradeSnapshotSchema (const)",
"VIEW_LAYOUT_WITHOUT_BINDING (const)",
"ValidationError (type)",
"ValidationErrorSchema (const)",
"ValidationResult (type)",
Expand All@@ -1885,6 +1892,8 @@
"VersionConstraint (type)",
"VersionConstraintSchema (const)",
"VulnerabilitySeverity (type)",
"checkFieldCompleteness (function)",
"checkViewCompleteness (function)",
"classifyRequiredCapability (function)",
"deriveNamespaceFromPackageId (function)",
"evaluateLockForDelete (function)",
Expand Down
Loading
Loading