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
18 changes: 18 additions & 0 deletions .changeset/17445-view-binding-calendar-fallback-corrected.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@objectstack/spec": patch
---

`view/layout-without-binding` — the `calendar` warning no longer tells an author the renderer falls back to `start_date` / `end_date`, because objectui deleted those floors; it names the refusal screen the renderer shows instead, and the key that clears it (#17445).

Two carriers asserted the same deleted behaviour: `VIEW_BINDING_BLOCKS`' calendar row in `src/kernel/functional-completeness.ts` (stated as *measured*, against "the built console 17.2.0") and the body of the warning `checkViewCompleteness` emits on that route. Re-measured on objectui `main` at `0cf2d6644` (2026-09-21), both halves of the path a `type: 'calendar'` list view takes:

- `packages/plugin-list/src/ListView.tsx`, `case 'calendar'` — the two literal floors are gone (objectui#7029); the branch restates only bindings the view declared.
- `packages/plugin-calendar/src/ObjectCalendar.tsx` — `getCalendarConfig` resolves `null` with neither a `calendar` block nor a flat `startDateField`, and the component renders its "Calendar configuration required" refusal screen, which names `startDateField` (objectui#8170 corrected that screen: `titleField` is not required).

So the old body was wrong twice — there is no fallback to literal field names, and the failure is not silent. What it was right about is the remedy, and that is the half the new body keeps: it names `calendar.startDateField`, `CalendarConfigSchema`'s one required key, and records that the event title resolves through the ADR-0079 display-name chain when `titleField` is omitted. The `fix` hint is unchanged.

- **The message is now per type.** `VIEW_BINDING_MESSAGE` carries an entry for a type whose measured outcome is not the generic literal-fallback sentence; the other five types receive the generic body unchanged. A type that stops flooring gets an entry, never a reworded universal — the two carriers drifted apart once, and the map is what makes correcting both one edit.
- **No severity moves — the severity is already ruled.** #16577 ruled **B** on 2026-09-11 (comment `5634033966`, card closed `completed`): the `type: 'calendar'` route stays warning-class under ADR-0078 §1, and it stays there *because* both doors are loud — loud at `os validate` (this warning) and loud at render (objectui#7029 deleted the `start_date` / `end_date` floors; `getCalendarConfig` returns `null` and the named refusal screen is reachable). That is exactly the premise re-measured here, so the corrected row is the evidence the standing ruling rests on, not a change whose severity consequence is pending.
- ⚠️ **The same reading found three sibling rows stale, and they are recorded rather than corrected** — out of this card's scope, and each changes what its row's severity rests on: `gantt`'s four floors are gone (objectui#7070, objectui#7499) and `ObjectGantt` refuses; `timeline`'s `created_at` floor is gone (objectui#7070) while its `titleField || 'name'` stands; `map`'s `locationField || 'location'` is gone on both faces (objectui#8169). The table now carries that re-measurement note so the three are not reused as current fact, and `kanban` / `tree` were re-read at the same ref and still say what they say.

No schema moved, no export moved and no accept set moved: this corrects prose and one warning string. `Clause-②: no`
59 changes: 55 additions & 4 deletions packages/spec/src/kernel/functional-completeness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,17 @@ describe('checkFieldCompleteness — the verified inert shapes go red', () => {
});

describe('checkViewCompleteness — layout bindings', () => {
// Six of the view `type` members carry a binding block. The renderer's
// fallback for every one is a literal field name (measured in objectui's
// ListView adapter — see the table's docblock), so the missing block is the
// same defect on all six, not a lesser one on the last three.
// Six of the view `type` members carry a binding block, and the missing
// block is the same defect on all six: the surface the author asked for is
// not the one that renders, while authoring reports success.
//
// ⛔ What it is NOT is one shared mechanism. This comment used to say the
// renderer's fallback for every one is a literal field name; objectui has
// been deleting those floors one view type at a time, and for `calendar`
// the renderer now REFUSES by name instead (#17445). The rule fires on all
// six either way — that is what this pin holds — and the per-type
// mechanism belongs to the table's docblock, which states each row's own
// measurement and which of them have gone stale.
it.each(['kanban', 'calendar', 'gantt', 'timeline', 'map', 'tree'])('flags a %s view missing its block as a WARNING', (type) => {
const f = only(checkViewCompleteness({ type }) as never);
expect(f.rule).toBe(VIEW_LAYOUT_WITHOUT_BINDING);
Expand Down Expand Up @@ -151,6 +158,50 @@ describe('checkViewCompleteness — layout bindings', () => {
expect(checkViewCompleteness({ type: 'tree', tree: { parentField: 'parent' } })).toEqual([]);
});

it('the calendar body describes the REFUSAL, not a deleted literal fallback (#17445)', () => {
// Re-measured on objectui `main` at `0cf2d6644` (2026-09-21), both halves
// of the path: `ListView.tsx`'s `case 'calendar'` restates only declared
// bindings — objectui#7029 deleted the `startDateField || 'start_date'` /
// `endDateField || 'end_date'` floors — and `ObjectCalendar`'s
// `getCalendarConfig` resolves `null`, so the component renders its
// "Calendar configuration required" refusal screen. The body asserted
// those floors as the reason for the warning, which is what this pin
// stops from coming back.
const f = only(checkViewCompleteness({ type: 'calendar' }) as never);
expect(f.message).not.toContain('falls back to literal default field names');
expect(f.message).toContain('Calendar configuration required');
expect(f.message).toContain('ObjectCalendar.tsx');
// ⛔ The half the correction had to PRESERVE: a warning an author meets at
// authoring time earns its place by naming the key to declare, not by
// reporting that something is missing.
expect(f.message).toContain('calendar.startDateField');
expect(f.fix).toContain('startDateField');
// Severity is untouched because it is already RULED, not because the
// question is open: #16577 ruled B (comment `5634033966`, card closed
// `completed` 2026-09-11) — a `calendar` route that refuses BY NAME
// stays warning-class under ADR-0078 §1, on the reasoning that BOTH
// doors are loud, `os validate` and render. That is the reading the
// assertions above re-measure, so this pin carries the ruling's
// evidence rather than standing in for a pending one.
expect(f.severity).toBe('warning');
});

it('the five types with no per-type body keep the generic one — an override, not a rewrite', () => {
// ⚠️ An honest pin: it records WHICH body each type receives, NOT that
// the body is true of each. The table's re-measurement note says `gantt`,
// `timeline` and `map` inherit this sentence pending corrections of their
// own; `kanban` and `tree` were re-read at the same ref and still floor a
// literal. Correcting one of the three means adding an entry beside
// `calendar`'s and moving that type out of this list — a deliberate edit,
// which is the point.
for (const type of ['kanban', 'gantt', 'timeline', 'map', 'tree']) {
const f = only(checkViewCompleteness({ type }) as never);
expect(f.rule).toBe(VIEW_LAYOUT_WITHOUT_BINDING);
expect(f.message).toContain('falls back to literal default field names');
expect(f.message).toContain(`A \`${type}\` view with no \`${type}\` block`);
}
});

it('names the schema-required keys in the timeline prescription', () => {
// `TimelineConfigSchema` requires exactly these two; the hint must not
// send an author to declare a block the parser then refuses.
Expand Down
134 changes: 121 additions & 13 deletions packages/spec/src/kernel/functional-completeness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,24 +216,66 @@ export function checkFieldCompleteness(def: unknown): CompletenessFinding[] {
}

/**
* The layout-specific config block each view type is inert without. The
* renderer falls back to LITERAL field names, so a view without its block
* renders — but empty — on any object that does not happen to declare those
* exact fields. Degrades rather than fully dies: WARNING, not error
* (ADR-0078 §1).
*
* Every entry names its measured renderer fallback. The verify-then-enforce
* The layout-specific config block each view type is inert without. What a
* view WITHOUT its block actually does is per type — a fallback to LITERAL
* field names that renders empty on any object not happening to declare
* them, a binding inferred from the object, or a refusal screen — so every
* row below states its own measured outcome and names the reading it came
* from. ⛔ Do not generalise one row to the next, and ⛔ do not restate a row
* as fact without re-reading the renderer: objectui is deleting these floors
* one view type at a time, so a row is only as true as its last measurement.
*
* Either way the author asked for a surface that does not render, while
* authoring reports success: WARNING, not error (ADR-0078 §1). ⚠️ The
* rubric's other half — refuse what renders NOTHING — was put to [#16577]
* for the `type: 'calendar'` route and is SETTLED, not pending: ruled B
* (director seat, comment `5634033966`; the card closed `completed` on
* 2026-09-11), the route STAYS warning-class and is carried at `warning` by
* the table below. The ruling turns on BOTH doors being loud — loud at
* `os validate` (this warning) and loud at render (objectui#7029 deleted the
* `'start_date'` / `'end_date'` floors; `ObjectCalendar.getCalendarConfig`
* returns `null` and the named refusal screen is reachable) — which is
* exactly what the `calendar` row below now measures. So this table moves no
* severity because the severity is already RULED, and the corrected row is
* the evidence that ruling rests on. ⛔ Reopening it takes a new ruling,
* not a re-read; a row going stale is a reason to re-measure the ROW.
*
* Every entry names its measured renderer binding. The verify-then-enforce
* gate this table sits behind (the audit's Tier-A had named only the first
* three) was discharged for `timeline` / `map` / `tree` by two measurements
* on record: the per-type props builder of objectui's ListView adapter
* (`packages/plugin-list/src/ListView.tsx`, read back verbatim from the built
* console 17.2.0), and a both-direction ablation on `os validate` — deleting
* a `timeline` block left `warnings: []` with `valid: true`, deleting the
* sibling `gantt` block warned as designed. The gate was working; the table
* was short.
* was short. ⚠️ That reading is dated — console 17.2.0 — and is no longer
* current for every row; see the re-measurement note under the table.
*
* - `kanban` → `groupBy = groupByField || groupField || <inferred>`
* - `calendar` → `startDateField || 'start_date'`, `endDateField || 'end_date'`
* - `calendar` → NO fallback, and no silence: the view is bound to nothing
* and the renderer says so on screen. Measured on objectui `main` at
* `0cf2d6644` (2026-09-21), both halves of the path a `type: 'calendar'`
* list view takes. ① `ListView.tsx`'s `case 'calendar'` restates only the
* bindings the view DECLARED — the `startDateField || 'start_date'` and
* `endDateField || 'end_date'` floors this row used to name were deleted
* by objectui#7029, whose own comment records that they were "field names
* no view had written and most objects do not carry". ② `ObjectCalendar`'s
* `getCalendarConfig` returns `null` with neither a `calendar` block nor a
* flat `startDateField`, and the `if (!calendarConfig)` arm renders the
* "Calendar configuration required" refusal screen, which names
* `startDateField` as the key to declare (objectui#8170 corrected that
* screen's second clause: `titleField` is NOT required). ⚠️ That literal
* is the text RENDERED at the pin: objectui#10101 landed AFTER the pin and
* moved it into a `tt('calendar.configRequired', …)` default, so on
* objectui's head a non-English locale renders other words for the same
* screen. Harmless for the console this repo ships — and the pin citation
* below is what reds when the pin moves past it. So the loss is
* total rather than partial — every record, on every object — and the
* author is told at render time as well as here. Both reads are identical
* at the pin this repo builds against (`.objectui-sha` = `87af769e9`), so
* this describes the console this repo SHIPS and not only objectui's head.
* This repo already records the same deletion one door over: the #13817
* check in `../ui/view.zod.ts` names objectui#7029 as its runtime half.
* - `gantt` → `startDateField || 'start_date'`, `endDateField || 'end_date'`,
* `progressField || 'progress'`, `dependenciesField || 'dependencies'` —
* fails CLOSED (`null` unless both dates resolve): a blank chart
Expand All @@ -255,6 +297,30 @@ export function checkFieldCompleteness(def: unknown): CompletenessFinding[] {
* still renders flat — the parent pointer has its own rule,
* {@link VIEW_TREE_WITHOUT_PARENT_FIELD}, below.
*
* ⚠️ RE-MEASUREMENT [#17445], objectui `main` at `0cf2d6644` (2026-09-21).
* The `calendar` row above is the one that card corrected. The same reading
* found three siblings still naming floors objectui has since deleted, and
* they are RECORDED here rather than corrected, because that correction is a
* separate finding to rule on and not a rider (the card scoped itself to
* `calendar`, and each of the three changes what its row's severity rests
* on):
*
* - `gantt` — all four floors gone (objectui#7070 for the dates, #7499 for
* `progressField` / `dependenciesField`); `ObjectGantt` REFUSES an absent
* date binding rather than drawing the blank chart this row describes.
* - `timeline` — `startDateField || 'created_at'` gone (objectui#7070 step
* ③, on the ruling 日期轴永不虚构), with a refusal screen in its place;
* the `titleField || 'name'` half of the row still stands.
* - `map` — `locationField || 'location'` gone on BOTH faces (objectui#8169):
* `ObjectMap` renders "Map configuration required" instead of an empty map.
* ⛔ That literal is also quoted in this rule's `map`-specific message
* below and pinned by `functional-completeness.test.ts`, so correcting the
* row means correcting the message and the pin together.
*
* `kanban` and `tree` were re-read at the same ref and still say what they
* say. ⛔ Until the three above are corrected, do not cite them — or the
* warning text they feed — as a current measurement.
*
* `gallery` is measured too (`titleField || 'name'`) and is deliberately NOT
* here: `GalleryConfigSchema` requires no key, so "has a `gallery` block"
* would assert nothing an author could act on, and the fallback mis-titles
Expand Down Expand Up @@ -296,6 +362,51 @@ const VIEW_BINDING_FIX: Readonly<Record<string, string>> = {
tree: "tree: { parentField: '<self_lookup_field>', labelField: '<text_field>' }",
};

/**
* The body of the `view/layout-without-binding` warning for a type whose
* measured outcome is NOT the generic literal-fallback sentence
* ({@link unboundBlockMessage}). A type with no entry here gets the generic
* body, so this map is the exception list, not a second copy of the table.
*
* ⛔ An entry is written from a reading of the renderer, never from the row
* above it — the two carriers went out of sync once already, which is what
* this map exists to make cheap to fix: {@link VIEW_BINDING_BLOCKS}'s
* `calendar` row and this rule's message BOTH asserted a
* `startDateField || 'start_date'` fallback that objectui#7029 had deleted,
* and correcting one without the other would have left the author reading the
* stale half.
*
* ⚠️ What the correction must PRESERVE is the prescription. The old sentence
* was wrong about the mechanism and still right about the remedy, and the
* remedy is the whole value of a warning an author meets at authoring time:
* it says which key to declare, not merely that something is missing.
*/
const VIEW_BINDING_MESSAGE: Readonly<Record<string, string>> = {
calendar:
'A `calendar` view with no `calendar` block declares no date axis, and the renderer does '
+ 'not invent one: objectui\'s `ListView.tsx` calendar branch forwards only the bindings the '
+ 'view DECLARED, so `getCalendarConfig` (objectui `ObjectCalendar.tsx`) resolves `null` and '
+ 'the view renders its "Calendar configuration required" refusal screen instead of records '
+ '— it parses and publishes clean, then shows no event on any object, not just on one that '
+ 'happens to lack a field. Declare `calendar.startDateField`, the block\'s one required key; '
+ 'the event title resolves through the ADR-0079 record display-name chain when `titleField` '
+ 'is omitted.',
};

/**
* The generic body — a view type whose renderer still floors the binding at a
* literal field name. It is what the five types with no
* {@link VIEW_BINDING_MESSAGE} entry receive, and the re-measurement note on
* {@link VIEW_BINDING_BLOCKS} says which of those five it is still true of
* (`kanban`, `tree`) and which three inherit it pending their own correction.
* ⛔ So it is the DEFAULT, never a universal: a type that stops flooring gets
* an entry above, not a reworded sentence here.
*/
const unboundBlockMessage = (type: string, block: string): string =>
`A \`${type}\` view with no \`${block}\` block is bound to nothing: the renderer falls `
+ 'back to literal default field names, which works only if the object happens to declare '
+ 'them — on any other object the view renders empty while authoring reports success.';

const isNonEmptyString = (v: unknown): v is string => typeof v === 'string' && v.length > 0;

/** A `map` block's coordinate binding — either of the two forms its schema documents. */
Expand Down Expand Up @@ -514,10 +625,7 @@ export function checkViewCompleteness(view: unknown, boundObject?: unknown): Com
rule: VIEW_LAYOUT_WITHOUT_BINDING,
severity: 'warning',
path: block,
message:
`A \`${type}\` view with no \`${block}\` block is bound to nothing: the renderer falls `
+ 'back to literal default field names, which works only if the object happens to declare '
+ 'them — on any other object the view renders empty while authoring reports success.',
message: VIEW_BINDING_MESSAGE[type] ?? unboundBlockMessage(type, block),
fix: VIEW_BINDING_FIX[type],
});
} else if (type === 'map' && isRec(view.map) && !hasMapCoordinateBinding(view.map)) {
Expand Down
Loading