Skip to content

Commit c4d1759

Browse files
os-billclaude
andauthored
docs(spec): record which calendar axis the #13817 guard gates — and which door carries the other (#17447)
* test(spec): pin the `type: 'calendar'` axis the #13817 guard does not gate The #13817 cross-field check gates one axis — `appearance.allowedVisualizations` includes 'calendar'. A view can also ask for a calendar by BEING one (`type: 'calendar'`), and that axis parses CLEAN at all three doors. The disposition was undocumented and so unreadable as a decision. Measured at every door before writing a line: the axis is not unwatched, it is carried by `checkViewCompleteness`'s VIEW_BINDING_BLOCKS at WARNING severity, under the ADR-0078 §1 rubric the `page` note already cites. The two doors have complementary coverage — the completeness check reads `type` only, the schema check reads `allowedVisualizations` only. Behaviour unchanged. Escalating the `type:` axis to a parse refusal would refuse a shape 17.3.0 accepts, so it is a published-surface narrowing and a separate ruling — the same disposition the `timeline` scope pin states. Also pins that `viewType` is not a second spelling of `type`: refused as an unknown key at the two authoring doors, and DROPPED at the `.strip()`ed overlay write door, leaving the defaulted `type: 'grid'`. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com> * docs(changeset): the list-view calendar guard's axis scope (#16577) `view.zod.ts` ships in `files[]` twice — as `src/**/*.zod.ts` and as the TSDoc carried into `dist/*.d.ts` — so a docblock-only edit moves published bytes and is not `skip-changeset`. Measured: the added prose greps in both `dist/view.zod-*.d.ts` and `.d.mts`, positive control from the same docblock hits the same two files, dark control zero. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 344d475 commit c4d1759

3 files changed

Lines changed: 168 additions & 9 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): record which axis the list-view calendar guard gates — and which it does not (#16577)
6+
7+
`checkListViewCalendarVisualization` gates ONE way of asking for a calendar: `appearance.allowedVisualizations` includes `'calendar'`. A view can also ask for one by BEING one — `type: 'calendar'` — and that axis parses CLEAN at all three doors (`ListViewSchema`, `ObjectListViewSchema`, `VIEW_METADATA_MEMBERS.listOverlay`). The disposition was correct but undocumented, so it read as an oversight rather than a decision.
8+
9+
**No behaviour changes.** Every parse verdict at every door is byte-identical before and after; the diff is a TSDoc block on the exported check (which ships in `dist/*.d.ts` and in `src/**/*.zod.ts`) plus pins in `view.test.ts`.
10+
11+
What the docblock now records, all of it measured rather than inferred:
12+
13+
- The `type:` axis is **not unwatched**. It is carried by `checkViewCompleteness`'s `VIEW_BINDING_BLOCKS` (`kernel/functional-completeness.ts`) at **warning** severity, under the same ADR-0078 §1 rubric this file's `page` note already cites — refuse what renders NOTHING, warn what degrades. The two doors have complementary coverage: the completeness check reads `type` only and is blind to `allowedVisualizations`; this check reads `allowedVisualizations` only and is blind to `type`.
14+
- `viewType` is **not** a second spelling of `type`. The two authoring doors refuse it as an unknown key; the `.strip()`ed overlay write door (`PUT /api/v1/meta/view`) DROPS it, so the view parses as the defaulted `type: 'grid'` — an author who spells it reaches a grid, never a calendar.
15+
16+
⛔ Escalating the `type:` axis to a parse refusal is deliberately NOT done here: it would refuse a shape 17.3.0 accepts, which is a published-surface narrowing and belongs to a ruling — the same disposition the `timeline` scope pin has stated since #13817.

packages/spec/src/ui/view.test.ts

Lines changed: 131 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ import {
5252
originFileOf,
5353
originOf,
5454
} from '../../scripts/lib/export-origins-testkit';
55+
56+
// [#16577] The door that carries the `type: '<layout>'` axis these view
57+
// schemas deliberately leave open — imported so the two-door split is asserted
58+
// from the side that can see both.
59+
import { checkViewCompleteness } from '../kernel/functional-completeness';
5560
describe('HttpMethodSubsetSchema', () => {
5661
it('should accept valid HTTP methods', () => {
5762
const methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const;
@@ -3870,16 +3875,24 @@ describe("ListViewSchema — the `page` view type (#13216)", () => {
38703875
// requires `calendar.startDateField` (ruled on #13748, option A; spec half)
38713876
// ============================================================================
38723877

3873-
describe("ListViewSchema — calendar in `appearance.allowedVisualizations` requires the `calendar:` block (#13817)", () => {
3874-
/** Walk `invalid_union` wrappers and return every issue, nested arms included. */
3875-
const flattenUnionIssues = (issues: z.ZodIssue[]): z.ZodIssue[] =>
3876-
issues.flatMap((i) => {
3877-
const nested = (i as unknown as { errors?: z.ZodIssue[][] }).errors;
3878-
return i.code === 'invalid_union' && Array.isArray(nested)
3879-
? [i, ...flattenUnionIssues(nested.flat())]
3880-
: [i];
3881-
});
3878+
/**
3879+
* Walk `invalid_union` wrappers and return every issue, nested arms included.
3880+
*
3881+
* Module-scoped (#16577) because two blocks need it: the #13817 refusals below
3882+
* and the `type: 'calendar'` scope pins further down. At the overlay door the
3883+
* union wraps a SHAPE failure in `invalid_union` with the per-arm truth nested
3884+
* one level down, so a search that reads only the top level finds nothing and
3885+
* reports it as "no such issue" rather than "issue is one level down".
3886+
*/
3887+
const flattenUnionIssues = (issues: z.ZodIssue[]): z.ZodIssue[] =>
3888+
issues.flatMap((i) => {
3889+
const nested = (i as unknown as { errors?: z.ZodIssue[][] }).errors;
3890+
return i.code === 'invalid_union' && Array.isArray(nested)
3891+
? [i, ...flattenUnionIssues(nested.flat())]
3892+
: [i];
3893+
});
38823894

3895+
describe("ListViewSchema — calendar in `appearance.allowedVisualizations` requires the `calendar:` block (#13817)", () => {
38833896
// The same three doors the page-mount check runs at — the check is attached
38843897
// at three separate points for the same zod-4 reason, and a missing
38853898
// re-attachment is invisible (the door keeps accepting, which reads as "no
@@ -3956,3 +3969,112 @@ describe("ListViewSchema — calendar in `appearance.allowedVisualizations` requ
39563969
expect(r.success).toBe(true);
39573970
});
39583971
});
3972+
3973+
// ============================================================================
3974+
// [#16577] The OTHER route to a calendar — `type: 'calendar'`. SCOPE PIN.
3975+
//
3976+
// #13817 gates ONE axis: `appearance.allowedVisualizations` includes
3977+
// `'calendar'`. A view can also ask for a calendar by BEING one —
3978+
// `type: 'calendar'` — and that axis is deliberately NOT gated here. The
3979+
// disposition was undocumented and therefore unreadable as a decision; these
3980+
// pins make it one, exactly as the `timeline` scope pin above does for the
3981+
// other visualizations.
3982+
//
3983+
// ## Measured, at every door, before this block was written
3984+
//
3985+
// | body | 3 schema doors | `checkViewCompleteness` |
3986+
// |----------------------------------------------|----------------|-------------------------|
3987+
// | `allowedVisualizations: ['calendar']`, no blk | REFUSED | no finding |
3988+
// | `type: 'calendar'`, no `calendar:` block | CLEAN | **warning** |
3989+
//
3990+
// So the axis is not unwatched — the two axes are carried by two doors with
3991+
// complementary coverage, at two severities. The door that decides
3992+
// `type: 'calendar'` is `checkViewCompleteness`'s `VIEW_BINDING_BLOCKS`
3993+
// (`packages/spec/src/kernel/functional-completeness.ts`), which warns rather
3994+
// than refuses under the ADR-0078 §1 rubric this file's `page` note already
3995+
// cites: refuse what renders NOTHING, warn what degrades.
3996+
//
3997+
// ⛔ Whether that severity should be escalated for `calendar` is NOT ruled
3998+
// here. #13748's own ruling says extensions of this guard are separate
3999+
// findings to measure first, not riders — the same sentence the `timeline`
4000+
// scope pin above records. Escalating would REFUSE a shape 17.3.0 accepts, so
4001+
// it is a published-surface narrowing and belongs to a ruling, not to a pin.
4002+
// ============================================================================
4003+
4004+
describe("ListViewSchema — the `type: 'calendar'` axis is NOT gated by the #13817 check (#16577)", () => {
4005+
describe.each(viewDoorsCarryingPageMountCheck)('%s', (_label, parse) => {
4006+
it("ACCEPTS `type: 'calendar'` with no `calendar:` block — the axis #13817 does not gate", () => {
4007+
const r = parse({ type: 'calendar', columns: ['name'] });
4008+
expect(r.success, r.success === false ? JSON.stringify((r as unknown as { error: z.ZodError }).error.issues) : '').toBe(true);
4009+
});
4010+
4011+
// The asymmetry is the BLOCK schema's, not the guard's: writing the block
4012+
// is what makes `startDateField` required. Omitting the block skips that
4013+
// schema entirely, which is why the two rows above differ.
4014+
it("REFUSES `type: 'calendar'` + `calendar: {}` at `calendar.startDateField` — CalendarConfigSchema's own required key", () => {
4015+
const r = parse({ type: 'calendar', columns: ['name'], calendar: {} });
4016+
expect(r.success).toBe(false);
4017+
const flat = flattenUnionIssues((r as { error: z.ZodError }).error.issues);
4018+
const issue = flat.find((i) => i.path.join('.').endsWith('calendar.startDateField'));
4019+
expect(issue, JSON.stringify((r as { error: z.ZodError }).error.issues)).toBeDefined();
4020+
});
4021+
});
4022+
4023+
// The door that DOES carry this axis, named here so the split above is
4024+
// readable from either side. A change of severity there fails this pin.
4025+
it('is carried by `checkViewCompleteness` instead — at WARNING severity', () => {
4026+
const findings = checkViewCompleteness({ type: 'calendar', columns: ['name'] });
4027+
expect(findings).toHaveLength(1);
4028+
expect(findings[0]!.severity).toBe('warning');
4029+
expect(findings[0]!.path).toBe('calendar');
4030+
});
4031+
4032+
// The complement, and the reason neither door is redundant: the completeness
4033+
// check reads `type` only, so the axis #13817 gates is invisible to it.
4034+
it('and `checkViewCompleteness` does NOT see the allowedVisualizations axis', () => {
4035+
expect(checkViewCompleteness({
4036+
type: 'grid',
4037+
columns: ['name'],
4038+
appearance: { allowedVisualizations: ['grid', 'calendar'] },
4039+
})).toEqual([]);
4040+
});
4041+
});
4042+
4043+
// ============================================================================
4044+
// [#16577] `viewType` is NOT a second spelling of `type` — measured, pinned.
4045+
//
4046+
// The finding that opened #16577 cited `viewType: 'calendar'` as a second way
4047+
// in. At the spec's doors it is not one, and the two authoring doors and the
4048+
// runtime write door disagree about HOW it fails — which is the part worth
4049+
// pinning, because one of the two is a silent downgrade.
4050+
// ============================================================================
4051+
4052+
describe('ListViewSchema — `viewType` is not a spelling of `type` (#16577)', () => {
4053+
it('is an UNKNOWN key at the two authoring doors — refused, not aliased', () => {
4054+
for (const [label, parse] of [
4055+
['ListViewSchema', (b: Record<string, unknown>) => ListViewSchema.safeParse(b)],
4056+
['ObjectListViewSchema', (b: Record<string, unknown>) => ObjectListViewSchema.safeParse(b)],
4057+
] as const) {
4058+
const r = parse({ viewType: 'calendar', columns: ['name'] });
4059+
expect(r.success, label).toBe(false);
4060+
const issue = (r as { error: z.ZodError }).error.issues.find((i) => i.code === 'unrecognized_keys');
4061+
expect(issue, `${label}: ${JSON.stringify((r as { error: z.ZodError }).error.issues)}`).toBeDefined();
4062+
}
4063+
});
4064+
4065+
// ⚠️ The overlay door is `PUT /api/v1/meta/view` — the only door a Studio
4066+
// tenant or an MCP/AI author has — and it is `.strip()`ed by design (it
4067+
// carries Studio's round-trip keys). So `viewType` is DROPPED there and the
4068+
// view parses as the defaulted `type: 'grid'`: an author who spells the
4069+
// retired alias gets a 200 and a grid, never a calendar. Pinned because the
4070+
// finding assumed the opposite, and because accepted-and-ignored is the
4071+
// failure mode this file's `VIEW_HISTORY` exists to record.
4072+
it("is STRIPPED at the overlay write door, leaving the defaulted `type: 'grid'`", () => {
4073+
const r = VIEW_METADATA_MEMBERS.listOverlay.safeParse({
4074+
object: 'crm_lead', viewKind: 'list', viewType: 'calendar', columns: ['name'],
4075+
});
4076+
expect(r.success).toBe(true);
4077+
expect((r as { data: Record<string, unknown> }).data).not.toHaveProperty('viewType');
4078+
expect((r as { data: { type?: unknown } }).data.type).toBe('grid');
4079+
});
4080+
});

packages/spec/src/ui/view.zod.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1746,6 +1746,27 @@ const VIEW_CALENDAR_ALLOWED_NEEDS_START_DATE =
17461746
* another visualization has the same shape is a separate finding to measure
17471747
* first, not a rider here (the #13748 ruling says so in those words).
17481748
*
1749+
* ⚠️ [#16577] Scope, the OTHER axis: this check reads
1750+
* `appearance.allowedVisualizations` and NOT `type`. A view that asks for a
1751+
* calendar by BEING one — `type: 'calendar'` with no `calendar:` block —
1752+
* parses CLEAN at all three doors, measured. That is not an unwatched shape:
1753+
* it is the axis `checkViewCompleteness`'s `VIEW_BINDING_BLOCKS`
1754+
* (`../kernel/functional-completeness.ts`) carries, at WARNING, under the same
1755+
* ADR-0078 §1 rubric the `page` note above cites — refuse what renders
1756+
* NOTHING, warn what degrades. The two axes are covered by two doors with
1757+
* complementary coverage: the completeness check reads `type` only and is
1758+
* blind to `allowedVisualizations`, this check reads `allowedVisualizations`
1759+
* only and is blind to `type`. ⛔ Escalating the `type` axis to a refusal is
1760+
* NOT ruled: it would refuse a shape 17.3.0 accepts, so it is a
1761+
* published-surface narrowing and a separate finding — the same disposition
1762+
* the `timeline` sentence above states. Both halves are pinned in
1763+
* `view.test.ts`, so a change to either is a deliberate edit.
1764+
*
1765+
* ⚠️ [#16577] `viewType` is NOT a second spelling of `type` at any of these
1766+
* doors: the two authoring doors refuse it as an unknown key, and the
1767+
* `.strip()`ed overlay door DROPS it, so the view parses as the defaulted
1768+
* `type: 'grid'` — an author who spells it reaches a grid, never a calendar.
1769+
*
17491770
* Attached with `.superRefine` at the same three doors as
17501771
* {@link checkListViewPageMount}, for the same zod-4 reason (refinements block
17511772
* `.omit()`/key-overwriting `.extend()`, so derived shapes re-attach): the

0 commit comments

Comments
 (0)