From 5ba2ab495d401783bb109bd8b4eccedcbd131d7e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 03:40:10 +0000 Subject: [PATCH 01/15] feat(spec,lint): a page gains an optional print declaration and the printable block subset The print page, card 1 of the B-prime ruling on #8346: PagePrintSchema on PageSchema.print (paper size, orientation, margins in millimetres, the running header and footer as the page's own header/footer regions, page numbers, page-break hints), the parse-time composition check checkPagePrintComposition, the printable block subset and its refusal reasons in the spec, and validatePrintPageBlocks in @objectstack/lint on all three commands and the page save door. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- packages/lint/src/authoring-rules.ts | 21 ++ packages/lint/src/index.ts | 8 + .../src/validate-print-page-blocks.test.ts | 163 +++++++++ .../lint/src/validate-print-page-blocks.ts | 132 ++++++++ packages/spec/authorable-surface/ui.json | 9 + packages/spec/json-schema.manifest/ui.json | 1 + .../src/type-alias-convention.pin.test.ts | 5 +- packages/spec/src/ui/page-print.test.ts | 205 ++++++++++++ packages/spec/src/ui/page.zod.ts | 311 +++++++++++++++++- 9 files changed, 849 insertions(+), 6 deletions(-) create mode 100644 packages/lint/src/validate-print-page-blocks.test.ts create mode 100644 packages/lint/src/validate-print-page-blocks.ts create mode 100644 packages/spec/src/ui/page-print.test.ts diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 3d74420ee5c..f9f6abcbd72 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -113,6 +113,7 @@ import { validateEmptyCombinators } from './validate-empty-combinators.js'; import { validateReferenceIntegrity } from './reference-integrity-suite.js'; import { validateComponentProps } from './validate-component-props.js'; import { validateComponentTypes } from './validate-component-types.js'; +import { validatePrintPageBlocks } from './validate-print-page-blocks.js'; import { validateResponsiveStyles } from './validate-responsive-styles.js'; import { validateJsxPages } from './validate-jsx-pages.js'; import { validateReactPages } from './validate-react-pages.js'; @@ -1192,6 +1193,26 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ 'authored config-file metadata only). Crossing is its own rollout card.', run: (stack) => validateComponentTypes(stack), }, + // #22158, card ① of the ruling of record on #8346 (letter B′): a page that + // declares `print` is a document, and every block in it must come from the + // spec's printable block subset (`PRINTABLE_PAGE_COMPONENT_TYPES`). Gating, + // and on the runtime door from birth — the rule's header says why the + // false-refusal budget `validateComponentTypes` is held on is zero here by + // construction: no page could carry `print` before this rule landed. + { + name: 'validatePrintPageBlocks', + tier: 'gating', + input: 'normalized', + commands: ALL, + source: 'packages/lint/src/validate-print-page-blocks.ts', + // Page-local: a `page` write's per-write snapshot holds exactly one page, + // its own (`runtime-gate.ts`), and that page is the whole input. The + // population it can refuse at the door is the pages that declare `print`, + // which was empty on every tenant the day the key was declared. + surfaces: CLI_AND_RUNTIME, + runtimeTypes: ['page'], + run: (stack) => validatePrintPageBlocks(stack), + }, // ADR-0065 — a styled node's responsiveStyles must be scopable (needs an // `id`), name real CSS properties + design tokens, and carry a `large` base. { diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index b364b8636d3..bb4a9dc732d 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -580,6 +580,14 @@ export { } from './validate-component-types.js'; export type { ComponentTypeFinding } from './validate-component-types.js'; +// #22158 — the printable block subset inside a page that declares `print` +// (ruling B′ on #8346); the subset itself is `@objectstack/spec/ui`'s. +export { + validatePrintPageBlocks, + PRINT_PAGE_BLOCK_UNPRINTABLE, +} from './validate-print-page-blocks.js'; +export type { PrintPageBlockFinding } from './validate-print-page-blocks.js'; + export { validateChartBindings, CHART_DIMENSION_UNKNOWN, diff --git a/packages/lint/src/validate-print-page-blocks.test.ts b/packages/lint/src/validate-print-page-blocks.test.ts new file mode 100644 index 00000000000..6b1501654ac --- /dev/null +++ b/packages/lint/src/validate-print-page-blocks.test.ts @@ -0,0 +1,163 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The printable block subset gate (#22158, card ① of the ruling of record on + * #8346, letter B′): inside a page that declares `print`, every block must be a + * member of the spec's `PRINTABLE_PAGE_COMPONENT_TYPES`. + * + * Pinned in both directions, as the ruling asks ("with pins"): + * - EVERY refused vocabulary type is refused, one case per type, each at its + * own `type` path with its own reason — derived from the spec's map, so a + * type added there is pinned here the day it lands; + * - a print page built only from printable blocks, nested in every container + * position the walk reaches, passes with zero findings; + * - a page WITHOUT `print` is never judged, whatever it holds. + */ +import { describe, expect, it } from 'vitest'; +import { + PRINTABLE_PAGE_COMPONENT_TYPES, + PRINT_REFUSED_PAGE_COMPONENT_TYPES, + RETIRED_PAGE_COMPONENT_TYPES, +} from '@objectstack/spec/ui'; +import { validatePrintPageBlocks, PRINT_PAGE_BLOCK_UNPRINTABLE } from './validate-print-page-blocks.js'; + +const printPage = (components: unknown[], extra: Record = {}) => ({ + pages: [{ name: 'invoice_print', type: 'record', object: 'invoice', regions: [{ name: 'main', components }], print: {}, ...extra }], +}); + +describe('refuses every block outside the printable subset inside a print page', () => { + it.each([...PRINT_REFUSED_PAGE_COMPONENT_TYPES])('refuses `%s` with its own reason', (type, reason) => { + const findings = validatePrintPageBlocks(printPage([{ type: 'element:text' }, { type }])); + expect(findings).toHaveLength(1); + const [f] = findings; + expect(f.rule).toBe(PRINT_PAGE_BLOCK_UNPRINTABLE); + expect(f.severity).toBe('error'); + expect(f.path).toBe('pages[0].regions[0].components[1].type'); + expect(f.where).toBe(`page "invoice_print" · ${type}`); + expect(f.message).toContain(`\`${type}\` cannot be placed in a print page`); + expect(f.message).toContain(`it ${reason}.`); + // The fix names the printable set, derived from the spec's own list. + for (const printable of PRINTABLE_PAGE_COMPONENT_TYPES) expect(f.hint).toContain(`\`${printable}\``); + }); + + it('names the two refusal reasons the ruling gives, on the blocks that carry them', () => { + const [grid] = validatePrintPageBlocks(printPage([{ type: 'object-grid' }])); + expect(grid.message).toMatch(/pages its rows/); + const [kanban] = validatePrintPageBlocks(printPage([{ type: 'object-kanban' }])); + expect(kanban.message).toMatch(/runs sideways past the screen edge/); + }); + + it('refuses a type the vocabulary does not declare at all — a plugin widget, an SDUI layout block', () => { + for (const type of ['flex', 'object-chart', 'acme:invoice-widget', 'custom.widget']) { + const findings = validatePrintPageBlocks(printPage([{ type }])); + expect(findings.map((f) => f.path), type).toEqual(['pages[0].regions[0].components[0].type']); + expect(findings[0].message, type).toMatch(/nothing answers for how it prints/); + } + }); + + it('reaches a refused block nested under printable containers, and every container position', () => { + const findings = validatePrintPageBlocks(printPage([ + { type: 'page:section', properties: { children: [{ type: 'object-grid' }] } }, + { type: 'page:card', properties: { children: [{ type: 'element:text' }], footer: [{ type: 'element:button' }] } }, + { type: 'page:tabs', properties: { items: [{ label: 'Lines', children: [{ type: 'record:related_list' }] }] } }, + ])); + expect(findings.map((f) => f.path)).toEqual([ + 'pages[0].regions[0].components[0].properties.children[0].type', + 'pages[0].regions[0].components[1].properties.footer[0].type', + 'pages[0].regions[0].components[2].type', + 'pages[0].regions[0].components[2].properties.items[0].children[0].type', + ]); + }); + + it('judges the running header and footer regions like any other region', () => { + const stack = { + pages: [{ + name: 'letter', + type: 'home', + regions: [ + { name: 'header', components: [{ type: 'page:header' }] }, + { name: 'main', components: [{ type: 'element:text' }] }, + { name: 'footer', components: [{ type: 'nav:breadcrumb' }] }, + ], + print: { repeatHeader: true, repeatFooter: true }, + }], + }; + expect(validatePrintPageBlocks(stack).map((f) => f.path)).toEqual([ + 'pages[0].regions[0].components[0].type', + 'pages[0].regions[2].components[0].type', + ]); + }); +}); + +describe('admits a print page built only from printable blocks', () => { + it('passes every printable type, nested in every container position the walk reaches', () => { + const leaves = [...PRINTABLE_PAGE_COMPONENT_TYPES] + .filter((t) => !['page:section', 'page:card', 'page:footer'].includes(t)) + .map((type) => ({ type })); + const stack = printPage([ + ...leaves, + { type: 'page:section', properties: { children: leaves } }, + { type: 'page:card', properties: { children: leaves, footer: [{ type: 'element:text' }] } }, + { type: 'page:footer', properties: { children: [{ type: 'element:divider' }, { type: 'element:text' }] } }, + ]); + expect(validatePrintPageBlocks(stack)).toEqual([]); + }); + + it('passes the invoice the ruling describes: letterhead, field blocks, the lines table, totals, a footer', () => { + const stack = { + pages: [{ + name: 'invoice_print', + type: 'record', + object: 'invoice', + regions: [ + { name: 'header', components: [{ type: 'element:image', properties: { src: '/logo.png', alt: 'ACME' } }, { type: 'element:text', properties: { content: 'INVOICE' } }] }, + { + name: 'main', + components: [ + { type: 'record:highlights', properties: { fields: ['name', 'invoice_date', 'due_date'] } }, + { type: 'record:details', properties: { fields: ['customer', 'billing_address'] } }, + { type: 'record:line_items', properties: { childObject: 'invoice_line', relationshipField: 'invoice', columns: [{ name: 'description' }, { name: 'amount', type: 'currency' }], readonly: true } }, + { type: 'element:number', properties: { object: 'invoice_line', field: 'amount', aggregate: 'sum' } }, + ], + }, + { name: 'footer', components: [{ type: 'element:divider' }, { type: 'element:text', properties: { content: 'Payment due within 30 days.' } }] }, + ], + print: { paperSize: 'A4', margins: { top: 15, bottom: 15 }, repeatHeader: true, repeatFooter: true, pageNumbers: true }, + }], + }; + expect(validatePrintPageBlocks(stack)).toEqual([]); + }); +}); + +describe('judges nothing outside a print page', () => { + it('a page without `print` may hold any block', () => { + const stack = { + pages: [{ + name: 'account_record', + regions: [{ name: 'main', components: [...PRINT_REFUSED_PAGE_COMPONENT_TYPES.keys()].map((type) => ({ type })) }], + }], + }; + expect(validatePrintPageBlocks(stack)).toEqual([]); + }); + + it('only the print page of two is judged', () => { + const stack = { + pages: [ + { name: 'screen', regions: [{ name: 'main', components: [{ type: 'object-grid' }] }] }, + { name: 'paper', regions: [{ name: 'main', components: [{ type: 'object-grid' }] }], print: {} }, + ], + }; + expect(validatePrintPageBlocks(stack).map((f) => f.where)).toEqual(['page "paper" · object-grid']); + }); + + it('leaves a RETIRED type to the parse and `component-type-unknown` — no second finding at the same node', () => { + for (const type of RETIRED_PAGE_COMPONENT_TYPES.keys()) { + expect(validatePrintPageBlocks(printPage([{ type }])), type).toEqual([]); + } + }); + + it('a malformed stack yields nothing rather than throwing', () => { + expect(validatePrintPageBlocks({})).toEqual([]); + expect(validatePrintPageBlocks({ pages: [null, 'x', { print: {} }] })).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-print-page-blocks.ts b/packages/lint/src/validate-print-page-blocks.ts new file mode 100644 index 00000000000..7e3eae2d633 --- /dev/null +++ b/packages/lint/src/validate-print-page-blocks.ts @@ -0,0 +1,132 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The PRINTABLE BLOCK SUBSET gate — card ① (#22158) of the ruling of record on + * #8346 (letter B′, 6051470224): "A document is a page with a print + * declaration … A printable block subset is defined and linted: a block that + * virtualises rows or lays out responsively is refused inside a print page, + * with pins." + * + * ## What this rule does + * + * A page that declares `print` is a document, and a document prints only what + * it draws in full. So inside such a page — at any depth the shared page walk + * reaches (`walkPageComponents`: every region, every container's `children`, + * a card's `footer`, a tab or accordion panel's `children`) — every component + * `type` must be a member of `@objectstack/spec`'s + * `PRINTABLE_PAGE_COMPONENT_TYPES`. Anything else is refused with + * `severity: 'error'`, at the node's `type`, with the reason + * `PRINT_REFUSED_PAGE_COMPONENT_TYPES` records for that type (it draws a window + * of its rows, it lays itself out to the screen, or it has nothing printable of + * its own) and the printable list as the fix. + * + * The subset is an ALLOW list. A type the platform vocabulary does not declare + * at all — a plugin widget, a kebab SDUI layout block — is refused too: nothing + * answers for how it prints. Both lists live in the spec, beside the `print` + * declaration, so the console's print rendering (card ②) reads the same set this + * rule enforces; this file restates neither. + * + * A page without `print` is not judged at all. The parse already refuses + * `print` on a page that does not print its own authored blocks (`slotted`, + * source-authored and `list` pages, a `full` page with no regions — + * `checkPagePrintComposition`), so the regions this walk visits are the whole + * printed body. A source-authored page yields nothing from the walk by design. + * + * A RETIRED type (`RETIRED_PAGE_COMPONENT_TYPES`) is skipped here: the parse + * refuses it by name, and `component-type-unknown` reports it with the + * retirement prescription. A second finding at the same node would only repeat + * a refusal the author already has, with a weaker fix. + * + * ## Why `error` from birth, and on the save door from birth + * + * Every page this rule can speak about carries `print`, a key the spec did not + * declare before this rule landed — `PageSchema` is closed, so no stored page + * row and no authored config file could carry it. The population of print + * pages is therefore EMPTY at landing, in the repo and in every tenant's + * stored rows alike: the false-refusal budget the sibling + * `validateComponentTypes` is still waiting on (measured over stored tenant + * rows) is zero by construction here, not by sampling. And the judgment is + * page-local, so a `page` write's per-write snapshot — exactly one page, its + * own — is all it reads. Hence `surfaces: CLI_AND_RUNTIME` with + * `runtimeTypes: ['page']` in the registry: Studio, REST `/meta` and MCP + * authors meet the same refusal `os build` gives. + */ + +import { + PRINTABLE_PAGE_COMPONENT_TYPES, + PRINT_REFUSED_PAGE_COMPONENT_TYPES, + RETIRED_PAGE_COMPONENT_TYPES, +} from '@objectstack/spec/ui'; +import { walkPageComponents, type AnyRec } from './page-walk.js'; +import { recordsOf } from './object-graph.js'; + +/** A block inside a print page that the printable block subset does not admit. */ +export const PRINT_PAGE_BLOCK_UNPRINTABLE = 'print-page-block-unprintable'; + +export interface PrintPageBlockFinding { + severity: 'error'; + /** Diagnostic rule id. */ + rule: string; + /** Human-readable location, e.g. `page "invoice_print" · object-grid`. */ + where: string; + /** Config path, e.g. `pages[0].regions[1].components[0].type`. */ + path: string; + /** What is wrong. */ + message: string; + /** How to fix it. */ + hint: string; +} + +function isRec(v: unknown): v is AnyRec { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +function strName(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +/** The printable types, spelled for the fix line — derived from the spec's set, never restated. */ +function printableList(): string { + return [...PRINTABLE_PAGE_COMPONENT_TYPES].map((t) => `\`${t}\``).join(', '); +} + +export function validatePrintPageBlocks(stack: AnyRec): PrintPageBlockFinding[] { + const findings: PrintPageBlockFinding[] = []; + if (!isRec(stack)) return findings; + + const pages = recordsOf(stack.pages); + for (let pi = 0; pi < pages.length; pi++) { + const page = pages[pi]; + if (!isRec(page) || !isRec(page.print)) continue; // only a print page is judged + const pageName = strName(page.name) ?? `#${pi}`; + + for (const { component, path } of walkPageComponents(page, `pages[${pi}]`)) { + const type = strName(component.type); + if (!type) continue; + if (PRINTABLE_PAGE_COMPONENT_TYPES.has(type)) continue; + // Refused by name at the parse, reported by `component-type-unknown` (header). + if (RETIRED_PAGE_COMPONENT_TYPES.has(type)) continue; + + const reason = PRINT_REFUSED_PAGE_COMPONENT_TYPES.get(type); + findings.push({ + severity: 'error', + rule: PRINT_PAGE_BLOCK_UNPRINTABLE, + where: `page "${pageName}" · ${type}`, + path: `${path}.type`, + message: + `\`${type}\` cannot be placed in a print page (this page declares \`print\`): ` + + (reason !== undefined + ? `it ${reason}.` + : 'it is not in the printable block subset, and nothing answers for how it prints — a ' + + 'custom or registered block, or an SDUI layout block, declares no printed form.') + + ' A print page prints exactly the blocks it draws, in full, so every block in it must come ' + + 'from the printable block subset.', + hint: + `Replace or remove the block. The printable blocks are ${printableList()}. ` + + 'If the page is not a document, delete its `print` declaration instead.', + }); + } + } + + return findings; +} diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index ed790fd2b5d..a3aaf5ca157 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -1059,6 +1059,7 @@ "ui/Page:label", "ui/Page:name", "ui/Page:object", + "ui/Page:print", "ui/Page:regions", "ui/Page:requires", "ui/Page:slots", @@ -1115,6 +1116,14 @@ "ui/PageNavItem:requiresService", "ui/PageNavItem:type", "ui/PageNavItem:visible", + "ui/PagePrint:avoidBreakInside", + "ui/PagePrint:margins", + "ui/PagePrint:orientation", + "ui/PagePrint:pageNumbers", + "ui/PagePrint:paperSize", + "ui/PagePrint:repeatFooter", + "ui/PagePrint:repeatHeader", + "ui/PagePrint:repeatTableHeaders", "ui/PageRegion:components", "ui/PageRegion:name", "ui/PageRegion:width", diff --git a/packages/spec/json-schema.manifest/ui.json b/packages/spec/json-schema.manifest/ui.json index 57c426c4c5c..c41684a5744 100644 --- a/packages/spec/json-schema.manifest/ui.json +++ b/packages/spec/json-schema.manifest/ui.json @@ -118,6 +118,7 @@ "ui/PageContainerProps", "ui/PageHeaderProps", "ui/PageNavItem", + "ui/PagePrint", "ui/PageRegion", "ui/PageTabsProps", "ui/PageType", diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 8f99fd872b5..7621d69dea0 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -1547,7 +1547,10 @@ export type Iso_ui_notification__NotificationTypeSchema = Assert, z.infer< typeof M163.PageComponentType > >>; -export type Iso_ui_page__PageTypeSchema = Assert, z.infer< typeof M163.PageTypeSchema > >>; +// `PagePrintSchema` (#22158) joins the family the day it lands: no defaults, +// no transforms, so `PagePrint` is the only name it carries. +export type Iso_ui_page__PagePrintSchema = Assert, z.infer< typeof M163.PagePrintSchema > >>; +export type Iso_ui_page__PageTypeSchema =Assert, z.infer< typeof M163.PageTypeSchema > >>; // ui/report.zod.ts // `JoinedReportBlockSchema` left the family on #19920: its `z.ZodTypeAny` diff --git a/packages/spec/src/ui/page-print.test.ts b/packages/spec/src/ui/page-print.test.ts new file mode 100644 index 00000000000..8c60804573c --- /dev/null +++ b/packages/spec/src/ui/page-print.test.ts @@ -0,0 +1,205 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The print page (#22158, card ① of the ruling of record on #8346, letter B′): + * the `print` declaration on `PageSchema`, the parse-time composition check, + * and the printable block subset's classification of the component vocabulary. + * + * The block subset itself is ENFORCED by `@objectstack/lint`'s + * `validatePrintPageBlocks` and pinned there per refused type; what is pinned + * here is the contract both ends read — that the subset and the refusal + * reasons classify every type the vocabulary declares, exactly once, so a type + * added to the vocabulary tomorrow cannot reach a print page unclassified. + */ + +import { describe, expect, it } from 'vitest'; + +import { + PageSchema, + PagePrintSchema, + PRINTABLE_PAGE_COMPONENT_TYPES, + PRINT_REFUSED_PAGE_COMPONENT_TYPES, + RETIRED_PAGE_COMPONENT_TYPES, +} from './page.zod'; +import { KNOWN_COMPONENT_TYPES } from './component-type-vocabulary'; + +const BASE = { name: 'invoice_print', label: 'Invoice', type: 'record', object: 'invoice' } as const; +const REGIONS = [ + { name: 'header', components: [{ type: 'element:text', properties: { content: 'ACME Ltd.' } }] }, + { name: 'main', components: [{ type: 'record:details' }] }, + { name: 'footer', components: [{ type: 'element:text', properties: { content: 'Thank you.' } }] }, +]; + +function issuesOf(value: unknown): Array<{ path: string; message: string; code: string }> { + const result = PageSchema.safeParse(value); + if (result.success) return []; + return result.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message, code: i.code })); +} + +describe('the `print` declaration', () => { + it('accepts a print page carrying every declared key', () => { + const parsed = PageSchema.parse({ + ...BASE, + regions: REGIONS, + print: { + paperSize: 'A4', + orientation: 'landscape', + margins: { top: 15, right: 12, bottom: 15, left: 12 }, + repeatHeader: true, + repeatFooter: true, + pageNumbers: true, + repeatTableHeaders: true, + avoidBreakInside: true, + }, + }); + expect(parsed.print).toEqual({ + paperSize: 'A4', + orientation: 'landscape', + margins: { top: 15, right: 12, bottom: 15, left: 12 }, + repeatHeader: true, + repeatFooter: true, + pageNumbers: true, + repeatTableHeaders: true, + avoidBreakInside: true, + }); + }); + + it('accepts an empty declaration — every key is optional, and none materializes a default', () => { + expect(PageSchema.parse({ ...BASE, regions: REGIONS, print: {} }).print).toEqual({}); + }); + + it('declares exactly the keys the ruling names, and no more', () => { + expect(Object.keys(PagePrintSchema.shape).sort()).toEqual([ + 'avoidBreakInside', + 'margins', + 'orientation', + 'pageNumbers', + 'paperSize', + 'repeatFooter', + 'repeatHeader', + 'repeatTableHeaders', + ]); + }); + + it('refuses a paper size outside the closed set', () => { + expect(issuesOf({ ...BASE, regions: REGIONS, print: { paperSize: 'A3' } }).map((i) => i.path)) + .toEqual(['print.paperSize']); + }); + + it('refuses a negative margin, and a margin side the closed object does not declare', () => { + const issues = issuesOf({ ...BASE, regions: REGIONS, print: { margins: { top: -1, middle: 4 } } }); + expect(issues.map((i) => i.path).sort()).toEqual(['print.margins', 'print.margins.top']); + expect(issues.find((i) => i.path === 'print.margins')!.message).toMatch(/these print margins: `middle`/); + }); + + it('answers an undeclared key with the declared one (`paper` → `paperSize`)', () => { + const [issue] = issuesOf({ ...BASE, regions: REGIONS, print: { paper: 'A4' } }); + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.message).toMatch(/this print declaration: `paper`[\s\S]*`paperSize`/); + }); + + it('answers a `header` block list with the region it belongs in', () => { + const [issue] = issuesOf({ ...BASE, regions: REGIONS, print: { header: [] } }); + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.message).toMatch(/running header is the page's own `header` region/); + }); + + it('answers a page-level `pdf` key with `print`', () => { + const [issue] = issuesOf({ ...BASE, regions: REGIONS, pdf: {} }); + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.message).toMatch(/`pdf` → `print`/); + }); +}); + +describe('checkPagePrintComposition — a print page prints exactly the blocks it authors', () => { + it.each([ + ['slotted', { kind: 'slotted' }, /kind: 'slotted'.*synthesized default layout/s], + ['html', { kind: 'html', source: 'Card' }, /kind: 'html'.*compiled from `source`/s], + ['jsx', { kind: 'jsx', source: 'Card' }, /kind: 'jsx'.*compiled from `source`/s], + ['react', { kind: 'react', source: 'Card' }, /kind: 'react'.*executed from `source`/s], + ])('refuses `print` on a `%s` page, at `print`', (_kind, page, message) => { + const issues = issuesOf({ ...BASE, ...page, regions: REGIONS, print: {} }); + expect(issues.map((i) => [i.path, i.code])).toEqual([['print', 'custom']]); + expect(issues[0].message).toMatch(message); + }); + + it('refuses `print` on a `list` page and names the list print control instead', () => { + const issues = issuesOf({ ...BASE, type: 'list', regions: REGIONS, print: {} }); + expect(issues.map((i) => [i.path, i.code])).toEqual([['print', 'custom']]); + expect(issues[0].message).toMatch(/interfaceConfig\.allowPrinting/); + }); + + it('refuses `print` on a full page with no regions — it would draw the synthesized default layout', () => { + for (const value of [{ ...BASE, print: {} }, { ...BASE, regions: [], print: {} }]) { + const issues = issuesOf(value); + expect(issues.map((i) => [i.path, i.code])).toEqual([['print', 'custom']]); + expect(issues[0].message).toMatch(/synthesized default layout/); + } + }); + + it.each([ + ['repeatHeader', 'header'], + ['repeatFooter', 'footer'], + ])('refuses `print.%s` on a page with no `%s` region, at the key', (key, region) => { + const regions = REGIONS.filter((r) => r.name !== region); + const issues = issuesOf({ ...BASE, regions, print: { [key]: true } }); + expect(issues.map((i) => [i.path, i.code])).toEqual([[`print.${key}`, 'custom']]); + expect(issues[0].message).toContain(`declares no region named \`${region}\``); + // `false` repeats nothing, so it needs no region. + expect(issuesOf({ ...BASE, regions, print: { [key]: false } })).toEqual([]); + }); + + it('accepts `print` on a record, home and app page, the `full` default kind included', () => { + for (const type of ['record', 'home', 'app'] as const) { + expect(issuesOf({ ...BASE, type, regions: REGIONS, print: {} })).toEqual([]); + expect(issuesOf({ ...BASE, type, kind: 'full', regions: REGIONS, print: {} })).toEqual([]); + } + }); + + it('leaves a page without `print` untouched — the slotted and list pages it refuses above still parse', () => { + expect(issuesOf({ ...BASE, kind: 'slotted' })).toEqual([]); + expect(issuesOf({ ...BASE, type: 'list' })).toEqual([]); + expect(issuesOf({ ...BASE })).toEqual([]); + }); +}); + +describe('the printable block subset classifies the whole component vocabulary', () => { + const live = [...KNOWN_COMPONENT_TYPES].filter((t) => !RETIRED_PAGE_COMPONENT_TYPES.has(t)); + + it('every live vocabulary type is either printable or refused with a reason — never neither', () => { + const unclassified = live.filter( + (t) => !PRINTABLE_PAGE_COMPONENT_TYPES.has(t) && !PRINT_REFUSED_PAGE_COMPONENT_TYPES.has(t), + ); + expect(unclassified).toEqual([]); + }); + + it('no type is both printable and refused', () => { + expect([...PRINTABLE_PAGE_COMPONENT_TYPES].filter((t) => PRINT_REFUSED_PAGE_COMPONENT_TYPES.has(t))) + .toEqual([]); + }); + + it('neither list names a type the vocabulary does not declare, or one it retired', () => { + const stale = [...PRINTABLE_PAGE_COMPONENT_TYPES, ...PRINT_REFUSED_PAGE_COMPONENT_TYPES.keys()] + .filter((t) => !KNOWN_COMPONENT_TYPES.has(t) || RETIRED_PAGE_COMPONENT_TYPES.has(t)); + expect(stale).toEqual([]); + }); + + it('keeps the ruling\'s named printable kinds in the subset: field blocks, a table of all rows, text, images', () => { + for (const t of ['record:details', 'record:line_items', 'element:text', 'element:image']) { + expect(PRINTABLE_PAGE_COMPONENT_TYPES.has(t), t).toBe(true); + } + }); + + it('keeps the ruling\'s named refusals out of it: blocks that window their rows or lay out to the screen', () => { + for (const t of ['object-grid', 'record:related_list', 'object-kanban', 'object-calendar', 'page:sidebar']) { + expect(PRINT_REFUSED_PAGE_COMPONENT_TYPES.has(t), t).toBe(true); + } + }); + + it('words every refusal reason as the clause after "it", ending without a full stop', () => { + for (const [t, reason] of PRINT_REFUSED_PAGE_COMPONENT_TYPES) { + expect(reason, t).toMatch(/^[a-z]/); + expect(reason.endsWith('.'), t).toBe(false); + } + }); +}); diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index a4ac77be3a8..39d4b888de3 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -785,6 +785,284 @@ const PAGE_AUDIENCE_WRONG_LAYER = + 'permission sets, and bind those sets to people through positions ' + '(`sys_position_permission_set`), never on the page itself.'; +// ─── The print page (#22158: card ① of the #8346 ruling) ─────────────────── +// +// The ruling of record is 6051470224 on #8346 (letter B′, the maintainer's +// 「8346 B′」 of 2026-10-08): "A document is a page with a print declaration; +// no new template type." A printable document (an invoice, a weighbridge +// ticket, a delivery order, a letter, a monthly report) is authored as an +// ordinary `page`, in the page's own block vocabulary, and the page gains one +// optional `print` declaration: paper, margins, the running header and footer, +// page numbering, and page-break hints, each mapped to print CSS. A printable +// block subset is defined beside it and enforced at the authoring doors. +// +// What this card does NOT ship, and why the declaration is honest anyway: the +// browser print mapping, the printable rendering of each block and the record +// page's print action are card ② (objectui), the headless-Chromium render +// service and the archive are card ③, and the record-page "generate PDF" +// action is card ④. Until ② lands, nothing reads `print`. The key is therefore +// declared with its consumer named in every `.describe()` and carried in the +// liveness ledger as `planned` with `authorWarn` (`liveness/page.json`), so an +// author who writes it is told the layout is validated but not yet applied. +// The REFUSALS are live from this card: the parse refuses a print declaration +// on a page that does not print its own authored blocks +// ({@link checkPagePrintComposition}), and `os validate` / `os build` / +// `os lint` and the metadata save door refuse a block outside the subset +// inside a print page (`@objectstack/lint`'s `validatePrintPageBlocks`). +// +// The running header and footer are the page's OWN `header` and `footer` +// regions, not a second component tree under `print`. A second tree would be +// a new composition root that every page walker (lint's `walkPageComponents`, +// the ADR-0087 conversion walker, the exported `walkAddressedPageComponents` +// behind `translatePage` and objectui's validator) would have to learn in +// lockstep; the regions are already walked by all of them, already designed in +// Studio's page designer, and already placed at the top and bottom of the page +// on screen, so the printed sheet and the on-screen preview agree. + +/** + * The printable block subset (ruling B′ on #8346): the page-component types a + * page that declares {@link PagePrintSchema | `print`} may hold, at any depth. + * + * Closed on purpose — an ALLOW list, not a deny list. A block is printable when + * it draws everything it declares, in full, laid out the same at any width: + * containers that draw every child, field blocks, a child-record table with no + * pagination, text, images and single values. Every other type is refused + * inside a print page with the reason {@link PRINT_REFUSED_PAGE_COMPONENT_TYPES} + * records for it, and a type the platform vocabulary does not declare at all (a + * plugin's own widget, an SDUI layout block) is refused because nothing + * answers for how it prints. Widening the set is additive and needs the block's + * printed rendering to exist; narrowing it is a breaking change. + * + * Read by `@objectstack/lint`'s `validatePrintPageBlocks` (the authoring + * doors) and, from card ②, by the console's print rendering — one list for + * both ends. + */ +export const PRINTABLE_PAGE_COMPONENT_TYPES: ReadonlySet = new Set([ + // Containers that draw every child they hold. + 'page:section', + 'page:card', + 'page:footer', + // Field blocks — the bound record's own values. + 'record:details', + 'record:highlights', + // The child-record table: no pagination, every line up to its `limit` cap. + 'record:line_items', + // Content. + 'element:text', + 'element:image', + 'element:divider', + 'element:definition-list', + 'element:repeater', + // Single values. + 'element:number', + 'object-metric', +]); + +/** + * Why each other type in the platform's component vocabulary is refused inside + * a print page — keyed by type, worded as the clause that follows "it" in the + * authoring refusal. Every type the vocabulary declares and does not retire is + * either in {@link PRINTABLE_PAGE_COMPONENT_TYPES} or here, never both (pinned + * in `page-print.test.ts`, so a new vocabulary member has to be classified the + * day it lands). The retired types are refused by name at the parse already + * ({@link RETIRED_PAGE_COMPONENT_TYPES}) and are not repeated here. + * + * Three reasons, from the ruling's own two plus the ones its measurement found: + * the block draws a WINDOW of its rows (a page, a `limit`, the nodes a viewer + * expanded), so a printed copy carries only part of the data; the block LAYS + * ITSELF OUT to the screen (docked panels, boards, canvases sized to the + * viewport); or the block has no printable content of its own (a control, an + * input, shell chrome, a panel switcher, per-viewer state). + */ +export const PRINT_REFUSED_PAGE_COMPONENT_TYPES: ReadonlyMap = new Map([ + // Draws a window of its rows. + ['record:related_list', 'draws only the first `limit` related records (5 unless set) behind a "View all" link, so a printed copy carries a window of the rows, never all of them'], + ['record:history', 'draws a window of the history feed (`limit`, 50 unless set), so a printed copy carries only part of it'], + ['record:activity', 'draws a window of the activity feed (`limit`) with comment and reaction controls, so a printed copy carries only part of it'], + ['object-grid', 'pages its rows (`pagination` / `pageSize`), so a printed copy carries only the page on screen'], + ['object-timeline', 'draws a window of its items (`limit`) along a time axis scrolled to the visible range'], + ['object-tree', 'draws only the nodes a viewer has expanded, so a printed copy depends on who expanded what'], + // Lays itself out to the screen. + ['page:sidebar', 'is a side column laid out beside the main region on a wide screen and folded away on a narrow one'], + ['page:header', 'lays its action bar out to the screen width (`maxVisible` / `mobileMaxVisible`) and carries the record chrome (star, copy id)'], + ['record:chatter', 'is a docked side panel laid out to the screen (`position`, `width`, `collapsible`) around a live feed'], + ['record:discussion', 'is a docked side panel laid out to the screen (`position`, `width`, `collapsible`) around a live feed'], + ['record:reference_rail', 'is a rail docked beside the record and laid out to the screen'], + ['object-kanban', 'is a board of columns that runs sideways past the screen edge'], + ['object-calendar', 'is a calendar grid sized to the screen, showing one period at a time'], + ['object-gantt', 'is a timeline canvas sized to the screen and scrolled to the visible range'], + ['object-map', 'is an interactive tile map sized to the screen'], + // No printable content of its own. + ['page:tabs', 'shows one panel at a time, so the panels not on screen never reach paper'], + ['page:accordion', 'folds its panels, so a closed panel never reaches paper'], + ['record:path', 'is an interactive stage control; the current stage is a field value, which `record:details` prints'], + ['record:alert', 'is a banner each viewer can dismiss, so what prints would depend on who prints it'], + ['record:quick_actions', 'is a row of action controls, with nothing to print'], + ['element:button', 'is an action control, with nothing to print'], + ['element:record_picker', 'is an input control, with nothing to print'], + ['element:text_input', 'is an input control, with nothing to print'], + ['element:metadata_viewer', 'is an interactive metadata browser, not document content'], + ['action:button', 'is an action control, with nothing to print'], + ['action:group', 'is a group of action controls, with nothing to print'], + ['action:menu', 'is an action menu, with nothing to print'], + ['action:icon', 'is an action control, with nothing to print'], + ['object-form', 'is an input form, laid out to the screen (`mobile`); print a record\'s values with `record:details`'], + ['object-master-detail-form', 'is an input form; print a record\'s values with `record:details` and its lines with `record:line_items`'], + ['app:launcher', 'is shell navigation chrome, not document content'], + ['nav:menu', 'is shell navigation chrome, not document content'], + ['nav:breadcrumb', 'is shell navigation chrome, not document content'], + ['global:search', 'is shell chrome, not document content'], + ['global:notifications', 'is shell chrome, not document content'], + ['cloud-connection:panel', 'is a console administration widget, not document content'], + ['marketplace:installed-list', 'is a console administration widget, not document content'], + ['mcp:connect-agent', 'is a console administration widget, not document content'], + ['ai:suggestion', 'is an AI suggestion generated for each viewer, not document content'], +]); + +/** + * Page `print` declaration (ruling B′ on #8346) — the paper a page prints on. + * Its presence makes the page a PRINT PAGE: the printable block subset applies + * to every block in it ({@link PRINTABLE_PAGE_COMPONENT_TYPES}), and the page + * must print its own authored blocks ({@link checkPagePrintComposition}). + * + * Minimal by ruling: paper size and orientation, margins, the running header + * and footer, page numbering and the page-break hints, and nothing else. Every + * key names its print-CSS mapping and its consumer. Until the console's print + * rendering lands (card ② of the ruling), nothing reads any of them — the + * liveness ledger carries each as `planned`. + */ +export const PagePrintSchema = lazySchema(() => strictObject({ + surface: 'this print declaration', + history: 'A key a print declaration does not declare would otherwise be dropped in silence, and the page would print without the setting.', + aliases: { + paper: 'paperSize', size: 'paperSize', format: 'paperSize', pageSize: 'paperSize', + layout: 'orientation', direction: 'orientation', + margin: 'margins', padding: 'margins', + numbering: 'pageNumbers', pageNumbering: 'pageNumbers', showPageNumbers: 'pageNumbers', + repeatTableHeader: 'repeatTableHeaders', repeatHeaders: 'repeatTableHeaders', + keepTogether: 'avoidBreakInside', avoidBreaks: 'avoidBreakInside', + }, + guidance: { + header: 'the running header is the page\'s own `header` region — author its blocks in `regions` under `name: \'header\'` and set `repeatHeader: true` to repeat it on every sheet', + footer: 'the running footer is the page\'s own `footer` region — author its blocks in `regions` under `name: \'footer\'` and set `repeatFooter: true` to repeat it on every sheet', + template: 'a print page IS the template — there is no separate template key; the page\'s own blocks are the document body', + }, +}, { + paperSize: z.enum(['A4', 'A5', 'Letter', 'Legal']).optional() + .describe("Paper size — the size keyword of the print CSS `@page { size }` rule (A4 when omitted). Consumer: the console's browser print rendering, and later the server-side PDF renderer; until it ships, nothing applies it."), + orientation: z.enum(['portrait', 'landscape']).optional() + .describe("Sheet orientation — the orientation keyword of the `@page { size }` rule (portrait when omitted). Consumer: the console's browser print rendering, and later the server-side PDF renderer; until it ships, nothing applies it."), + margins: strictObject({ + surface: 'these print margins', + history: 'A side these margins do not declare would otherwise be dropped in silence, and the sheet would print with the default margin there.', + aliases: { up: 'top', down: 'bottom', start: 'left', end: 'right' }, + }, { + top: z.number().min(0).optional().describe('Top margin, in millimetres'), + right: z.number().min(0).optional().describe('Right margin, in millimetres'), + bottom: z.number().min(0).optional().describe('Bottom margin, in millimetres'), + left: z.number().min(0).optional().describe('Left margin, in millimetres'), + }).optional() + .describe("Sheet margins in MILLIMETRES, one number per side — the `@page { margin }` rule; a side left out takes the renderer's default. Consumer: the console's browser print rendering, and later the server-side PDF renderer; until it ships, nothing applies it."), + repeatHeader: z.boolean().optional() + .describe("Repeat the page's `header` region (its `regions` entry named `header`) at the top of every printed sheet — the running header. The parse refuses it on a page with no `header` region. Off when omitted: the region prints once, at the top of the first sheet. Consumer: the console's browser print rendering; until it ships, nothing applies it."), + repeatFooter: z.boolean().optional() + .describe("Repeat the page's `footer` region (its `regions` entry named `footer`) at the bottom of every printed sheet — the running footer. The parse refuses it on a page with no `footer` region. Off when omitted: the region prints once, after the body. Consumer: the console's browser print rendering; until it ships, nothing applies it."), + pageNumbers: z.boolean().optional() + .describe("Print the sheet number and the sheet count in the bottom margin of every sheet — the `@page` margin box with `counter(page)` and `counter(pages)`. Off when omitted. Consumer: the console's browser print rendering; until it ships, nothing applies it."), + repeatTableHeaders: z.boolean().optional() + .describe("Page-break hint: repeat a table's column headings at the top of every sheet the table runs onto (`thead { display: table-header-group }`). On when omitted. Consumer: the console's browser print rendering; until it ships, nothing applies it."), + avoidBreakInside: z.boolean().optional() + .describe("Page-break hint: keep each block whole on one sheet when it fits, moving it to the next sheet instead of splitting it (`break-inside: avoid` on every block). Off when omitted. Consumer: the console's browser print rendering; until it ships, nothing applies it."), +}).describe('Print declaration: paper size, orientation, margins, the running header and footer, page numbering and page-break hints. Its presence makes the page a print page, whose blocks must come from the printable block subset.')); + +/** The page kinds a print page may take: the one that prints its authored `regions` and nothing else. */ +const PRINT_PAGE_KINDS: readonly string[] = ['full']; + +/** + * The print-page composition check attached to {@link PageSchema} (ruling B′ + * on #8346): a page that declares `print` must print exactly the blocks it + * authors, because the printable block subset is judged on those blocks. + * + * Refused, each at its own path: + * - `print` on a `slotted` page — every slot it does not override is drawn + * from the synthesized default layout (related lists, the discussion feed), + * which the subset refuses and no author ever wrote; + * - `print` on an `html` / `jsx` / `react` page — its blocks are compiled (or, + * for `react`, executed) from `source`, so there are no authored blocks to + * judge; + * - `print` on a `list` page — it draws its records through `interfaceConfig` + * as a paged grid; the list's own print control is `allowPrinting`; + * - `print` on a `full` page with no `regions` — it draws the synthesized + * default layout instead; + * - `print.repeatHeader` / `print.repeatFooter` on a page with no region of + * that name — the running header and footer ARE those regions. + * + * A `kind` that is absent is the spec default, `full`, and `regions` absent is + * the default `[]`, so a `.shape` mirror without the defaults gets the same + * answer. Exported for the reason {@link checkPageSourceCompleteness} is, and + * attached by identifier — pinned in `object-refinement-check-exports.test.ts`. + */ +export function checkPagePrintComposition( + page: { kind?: string; type?: string; regions?: unknown; print?: unknown }, + ctx: z.RefinementCtx, +): void { + const print = page.print; + if (print === undefined || print === null || typeof print !== 'object') return; + const kind = page.kind ?? 'full'; + if (!PRINT_PAGE_KINDS.includes(kind)) { + const why = kind === 'slotted' + ? 'a slotted page draws every slot it does not override from the synthesized default layout ' + + '(related lists, the discussion feed), which no author wrote and the printable block subset refuses' + : `${kind === 'html' ? 'an' : 'a'} \`${kind}\` page's blocks are ${kind === 'react' ? 'executed' : 'compiled'} from \`source\`, so there are ` + + 'no authored blocks for the printable block subset to judge'; + ctx.addIssue({ + code: 'custom', + path: ['print'], + message: `\`print\` is refused on a \`kind: '${kind}'\` page: a print page prints exactly the blocks it ` + + `authors, and ${why}. Author the document as a \`kind: 'full'\` page with its blocks in \`regions\`.`, + }); + return; + } + if (page.type === 'list') { + ctx.addIssue({ + code: 'custom', + path: ['print'], + message: '`print` is refused on a `type: \'list\'` page: a list page draws its records through ' + + '`interfaceConfig` as a paged grid, not as authored blocks. To let users print a list as shown, ' + + 'set `interfaceConfig.allowPrinting: true`; to print a document, declare `print` on a `record`, ' + + '`home` or `app` page with its blocks in `regions`.', + }); + return; + } + const regions = Array.isArray(page.regions) ? page.regions : []; + if (regions.length === 0) { + ctx.addIssue({ + code: 'custom', + path: ['print'], + message: '`print` needs the document\'s blocks in `regions`: a `kind: \'full\'` page with no regions ' + + 'draws the synthesized default layout, which no author wrote and the printable block subset ' + + 'refuses. Author the document\'s blocks in `regions`.', + }); + return; + } + const regionNames = new Set( + regions + .map((r) => (r && typeof r === 'object' ? (r as { name?: unknown }).name : undefined)) + .filter((n): n is string => typeof n === 'string'), + ); + const declaration = print as { repeatHeader?: unknown; repeatFooter?: unknown }; + for (const [key, region] of [['repeatHeader', 'header'], ['repeatFooter', 'footer']] as const) { + if (declaration[key] !== true || regionNames.has(region)) continue; + ctx.addIssue({ + code: 'custom', + path: ['print', key], + message: `\`print.${key}\` repeats the page's \`${region}\` region on every printed sheet, and this page ` + + `declares no region named \`${region}\`. Add a \`{ name: '${region}', components: [...] }\` region ` + + `holding the ${region === 'header' ? 'letterhead' : 'footer'} blocks, or delete \`${key}\`.`, + }); + } +} + export const PageSchema = lazySchema(() => strictObject({ surface: 'this page', history: PAGE_HISTORY, @@ -800,6 +1078,7 @@ export const PageSchema = lazySchema(() => strictObject({ default: 'isDefault', jsx: 'source', html: 'source', code: 'source', content: 'source', dependencies: 'requires', plugins: 'requires', + printLayout: 'print', printSettings: 'print', printConfig: 'print', pdf: 'print', paper: 'print', }, guidance: { // The removals this file's own comments record. Each was a page type or a @@ -913,6 +1192,18 @@ export const PageSchema = lazySchema(() => strictObject({ /** ARIA accessibility attributes */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), + /** + * Print declaration (ruling B′ on #8346, card ① #22158) — makes this page a + * PRINT PAGE: a document (an invoice, a delivery order, a letter, a report) + * authored in the page's own blocks. See {@link PagePrintSchema} for the + * keys and their print-CSS mapping, {@link PRINTABLE_PAGE_COMPONENT_TYPES} + * for the blocks a print page may hold, and {@link checkPagePrintComposition} + * for the pages that may carry it. `planned` in the liveness ledger until the + * console's print rendering (card ②) reads it. + */ + print: PagePrintSchema.optional() + .describe("Print declaration — makes this page a print page, a document authored in the page's own blocks: paper size and orientation, margins in millimetres, the running header and footer (the page's own `header` / `footer` regions), page numbers and page-break hints, each mapped to print CSS. A print page is a `kind: 'full'` page with its blocks in `regions`, and every block in it must come from the printable block subset: containers that draw every child (`page:section`, `page:card`, `page:footer`), field blocks (`record:details`, `record:highlights`), the child-record table `record:line_items`, `element:text`, `element:image`, `element:divider`, `element:definition-list`, `element:repeater`, `element:number` and `object-metric`; `os validate` / `os build` / `os lint` and the metadata save door refuse any other block inside it. Consumer: the console's browser print rendering, and later the server-side PDF renderer; until it ships, the declaration is validated but nothing applies it. A list view's print button is `allowPrinting`, not this."), + /** * Override semantics for record pages. * @@ -1024,11 +1315,15 @@ export const PageSchema = lazySchema(() => strictObject({ // ADR-0080 §5 (`requires`): the key exists only on the kinds whose source is // compiled at save (#21459, ruling A). Attached by identifier for the same // reason as the check above. - .superRefine(checkPageRequiresKind)); -// PageSchema's cross-field rules are the two `kind` checks above: the -// ADR-0080/0081 source completeness check and the compiled-kind `requires` -// check. It once also required `recordReview`/`blankLayout` and `slots` (all -// removed — unrendered roadmap / "required-but-unauthorable" Studio traps). + .superRefine(checkPageRequiresKind) + // Ruling B′ on #8346 (#22158): a print page prints exactly the blocks it + // authors. Attached by identifier for the same reason as the checks above. + .superRefine(checkPagePrintComposition)); +// PageSchema's cross-field rules are the three checks above: the +// ADR-0080/0081 source completeness check, the compiled-kind `requires` +// check and the print-page composition check. It once also required +// `recordReview`/`blankLayout` and `slots` (all removed — unrendered roadmap / +// "required-but-unauthorable" Studio traps). export type Page = z.input; /** Post-parse shape of {@link Page} — defaults applied, transforms run (ADR-0122). */ @@ -1064,4 +1359,10 @@ export type ElementDataSourceParsed = z.infer; export type InterfacePageConfig = z.input; /** Post-parse shape of {@link InterfacePageConfig} — defaults applied, transforms run (ADR-0122). */ export type InterfacePageConfigParsed = z.infer; +/** + * The page `print` declaration. One shape: the schema has no defaults and no + * transforms, so the author state and the parsed state coincide (pinned in + * `type-alias-convention.pin.test.ts`, ADR-0122). + */ +export type PagePrint = z.input; export type PageComponentType = z.input; From 6759c50cf1367a86f11483cbd154b199ef72287d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 03:42:38 +0000 Subject: [PATCH 02/15] feat(spec): the list-export pdf prescription points at the print page; print rows in the liveness ledger The 17.0 retirement of 'pdf' stands; the sentence 'PDF export itself was declined as NOT PLANNED' stopped being true when PDF documents were re-planned for v18, so the prescription now names the view's allowPrinting for printing a list and a page that declares print for a document. The print declaration's eleven rows are planned with authorWarn on the container, naming card 2 of the #8346 ruling as the carrier. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- packages/spec/liveness/README.md | 2 +- packages/spec/liveness/page.json | 71 +++++++++++++++++++ packages/spec/liveness/state-counts/page.md | 2 +- .../spec/src/ui/list-view-export-options.ts | 29 +++++--- .../object-refinement-check-exports.test.ts | 25 ++++++- packages/spec/src/ui/view.test.ts | 14 +++- 6 files changed, 127 insertions(+), 16 deletions(-) diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 12f762b9063..24a2da91580 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -966,7 +966,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | tool | the inert authoring surface is now REMOVED, not merely marked: `category`/`permissions`/`active`/`builtIn` retired 2026-07-30 (#3896 close-out) after `requiresConfirmation` set the precedent (#3715, ADR-0033 §2). `permissions` promised an invocation gate nothing enforced and `active:false` withdrew nothing — false compliance, same shape as rls.enabled. The `.strict()` ToolSchema rejects each retired key with its prescription; the `tool-inert-authoring-keys-removed` conversion strips them from authored sources | | skill | `permissions` REMOVED 2026-07 (#3704); `triggerPhrases` REMOVED 2026-07-30 (#3896 close-out sweep — phrases were never matched; activation is `triggerConditions` + the agent's `skills[]` + /skill-name pinning) | | dataset | `measures.certified` (declared-but-unenforced governance flag) REMOVED in 16.0 (#2377) | -| page | live, plus the one dead tombstone below. Its one `planned` row, `requires`, flipped `live` 2026-10-02 (#20871): the save door refuses an html page whose written list disagrees with its source and stores the derived one, and boot hydration reports a stored page whose list names a plugin the deployment's SDUI manifest does not carry. Dead `assignedProfiles` REMOVED 2026-09-12 (ADR-0090 D2 + ADR-0049 — a per-page audience list named for the concept D2 deleted, with zero readers in either repo, so the page was open to everyone who could reach it). The row stays because `retiredKey` keeps the key in the walked shape (the `rls.priority` precedent). Its prior `live` verdict is the #12516 class twice over: the objectui bridge it cited never existed (lit control — two sibling objectui citations in the same file resolve), and the entry carried no `verifiedAt`, so nothing ever re-asked | +| page | live, plus the one dead tombstone below, plus the `print` declaration's eleven `planned` rows (#22158, 2026-10-08: the container, its eight keys and the four `margins` sides): card ① of the #8346 ruling (B′) declares the print page spec-first, so the declaration's REFUSALS are live (the parse's `checkPagePrintComposition`, lint's `validatePrintPageBlocks`) while what its keys configure has no reader until the console's print rendering, card ②, lands. The container row carries `authorWarn`, so an author who writes `print` is told the layout is not yet applied. Its earlier `planned` row, `requires`, flipped `live` 2026-10-02 (#20871): the save door refuses an html page whose written list disagrees with its source and stores the derived one, and boot hydration reports a stored page whose list names a plugin the deployment's SDUI manifest does not carry. Dead `assignedProfiles` REMOVED 2026-09-12 (ADR-0090 D2 + ADR-0049 — a per-page audience list named for the concept D2 deleted, with zero readers in either repo, so the page was open to everyone who could reach it). The row stays because `retiredKey` keeps the key in the walked shape (the `rls.priority` precedent). Its prior `live` verdict is the #12516 class twice over: the objectui bridge it cited never existed (lit control — two sibling objectui citations in the same file resolve), and the entry carried no `verifiedAt`, so nothing ever re-asked | | view | list/form drilled via `children` (#2998 Track B); list.{responsive,performance} + form.{defaultSort,aria} REMOVED 2026-07-30 (#3896 close-out sweep — list aria/data stay live); **form.data was that sweep's one CORRECTION** — the removal attempt broke the build (`defineForm` writes `data.provider='schema'` onto every metadata form, `metadata-protocol` serves it), so it stands `live` with re-verified evidence; form.{buttons,defaults} live (framework#1894 / #2998); audit-era DEAD lines superseded by re-verification. **The dead set is six, not the four removals above**: #4534 (the last #4001 batch, batch 6e) declared three CONTAINER-level keys this row had never classified — `name` and `label`, both `dead`, and `object`, `live`. All three are properties of the `views: [...]` *container*, not of a view: `name` is dead because authoring it changes nothing — an authored value restates the key the container already registers under or contradicts it — and `label` is container display metadata with no reader. Neither is `authorWarn`'d and both are deliberately KEPT — the metadata door itself stamps the save name into every saved view body (`normalizeViewMetadata`) and its overlay paths key on that copy, so tombstoning `name` would reject the platform's own saves (re-measured 2026-10-02, #20301 stage 2: the earlier attribution to artifact-shipped containers and the metadata-validation sweep was the door's stamp misread; neither carries one). `object` is the container's object binding, and it was *stripped on every parse* until #4534 declared it. Separately, the level-2 dead residue (userActions.buttons, addRecord.mode/formView, tabs[].order) is noted on parents and is **not** in the counts — one drill level only **#9340**: `list.map` declared — the eighth visualization block (`ListMapConfigSchema`), keys mirroring objectui plugin-map's documented read set. FLIPPED `planned` → `live` 2026-08-24 (#11442): objectui#5908 landed `resolveListMapConfig`, which merges the view-level `map` block over the legacy `options.map` bag before `ListView.tsx`'s `case 'map'` forwards it into `ObjectMap`, with the same merged config also feeding the visualization-switcher's capability gate so a view binding coordinates only in the spec block is no longer filtered out of `allowedVisualizations` either (objectui#5042) | | report | dataset-bound (ADR-0021); the aria/performance LEDGER entries were stale — the keys left the schema in the report-liveness close-out; deleted 2026-07-30 as hygiene. Audit-era `chart` DEAD superseded (framework#1890 / #3441) — live on non-joined reports only: a `joined` report's container `chart` is refused and `blocks[].chart` was removed (#20161, 2026-09-27; nothing drew either) | | dashboard | ADR-0021 dataset widgets (#3251; DashboardWidgetSchema `.strict()`); `aria`/`performance` (and widget `performance` + PerformanceConfigSchema) REMOVED 2026-07-30 (#3896 close-out sweep — no renderer applied any of them); audit-era `globalFilters`/`dateRange` DEAD superseded (framework#2501) **#4956**: `widgets` DRILLED — the row jumps 20 → 41 classified because all 22 widget-level keys enter the count at once. They had never been classified at all: the entry carried one blanket `live` plus a `note` asserting they were classified "in the DashboardWidgetSchema subtree", and no such subtree existed in any of the 28 ledger files. That gap, not any evidence, is what carried `widgets[].responsive` through the #3896 sweep that removed both its sibling `widgets[].performance` and its literal namesake `view.responsive` — `view` is drilled, so `list.responsive` got asked and went out. New dead 6 = `responsive` (retired #4876/#4995, tombstone keeps the row) + `colorVariant` + `actionUrl`/`actionType`/`actionIcon` + `aria`. The action trio is the sharpest: no renderer draws a per-widget action button at all (every `actionUrl` read in DashboardRenderer is scoped to `header.actions[]`), yet `validate-dashboard-action-refs.ts` enforces reference integrity on it and its docblock calls it "the per-widget button" — a lint guarding an affordance that does not exist. `requiresService` is the counter-example worth remembering: dead by every objectui measurement, and LIVE server-side (`filterDashboardForUser`, ADR-0057 D10) — judging a widget key from the renderer repo alone would have retired an enforced gate. `compareTo` is `live` on ONE path only (inline object-provider charts); on the ADR-0021 dataset path the string arms are dropped and `{ offset }` throws in the executor. **#6774** moves the row 33/8 → 34/7: `colorVariant` CORRECTED dead → live 2026-08-09, the enforce leg of #5010 ruling B landing from the renderer side (objectui#3359 / PR objectui#3799, absorbed by pin `09987b68`). Worth reading beside `requiresService` above, because it is the same lesson from the other end — that row warns against judging a widget key from the renderer repo alone, and this one is a `dead` verdict that was correct in this repo AND correct in the renderer repo on the day it was measured, and stopped being either when a cross-repo decision was implemented. A ledger row is a claim with a timestamp; `verifiedAt` is what makes the claim re-askable. It also empties the dashboard warn set, so the author-side lint now says nothing about any widget key — `dashboard` stays in the lint's TYPE_COLLECTIONS all the same (the `webhook`/`email_template` resolved state). **#17385** DRILLS `widgets.chartConfig` — 14 per-key verdicts where the row had carried one blanket `live`, re-measured against `.objectui-sha` pin `53ded82bf7a4`: 12 live (the nine chrome keys `chartConfigPresentation` lowers, plus `xAxis`/`yAxis`/`series`, whose PRESENTATION merges onto the derived bindings while `ChartAxis.field` and `ChartSeries.name` are dropped so membership stays with the dataset) and dead 2 — `type`, which parses and does nothing because the widget's own `type` owns the chart family, and `aria`, which has no reader on either face. Both are pinned as NEGATIVES in objectui, which is what makes them re-askable rather than merely asserted. ⚠️ The drill made SIX containers one level further down visible for the first time (`xAxis`/`yAxis`/`series`/`annotations`/`interaction`/`aria`, 39 child keys); they are RECORDED, not drilled — fanning this row's verdicts down over them would manufacture verdicts, and the evidence work is a separate measurement. Note the cell's previous last stated position (`34/7`) had already drifted one `dead` behind the generated artifact before this change; the counts columns are generated and are the authority | diff --git a/packages/spec/liveness/page.json b/packages/spec/liveness/page.json index 5738316c86f..6288330dbac 100644 --- a/packages/spec/liveness/page.json +++ b/packages/spec/liveness/page.json @@ -73,6 +73,77 @@ "status": "live", "note": "ARIA attributes applied by objectui renderers." }, + "print": { + "status": "planned", + "verifiedAt": "2026-10-08", + "authorWarn": true, + "authorHint": "Keep it: the print declaration is validated, and the printable block subset is enforced, but no renderer applies the paper, margins, running header and footer or page numbers yet — printing the page today prints the screen layout.", + "note": "PLANNED (#22158, card ① of the ruling of record 6051470224 on #8346, letter B′: \"A document is a page with a print declaration; no new template type\"). The declaration lands spec-first with its REFUSALS live — PageSchema's checkPagePrintComposition (packages/spec/src/ui/page.zod.ts#checkPagePrintComposition) refuses `print` on a page that does not print its own authored blocks, and @objectstack/lint's validatePrintPageBlocks (packages/lint/src/validate-print-page-blocks.ts#validatePrintPageBlocks) refuses a block outside PRINTABLE_PAGE_COMPONENT_TYPES inside a print page at os validate / os build / os lint and the page save door — while what the keys CONFIGURE (paper, margins, running header and footer, page numbers, page-break hints) has no reader anywhere yet. `authorWarn` per enforce-or-mark (the externalSharingModel shape): an author is told the layout is not yet applied, at every compile, until the console reads it. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit.", + "children": { + "paperSize": { + "status": "planned", + "verifiedAt": "2026-10-08", + "note": "PLANNED — maps to the size keyword of `@page { size }`. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin a58626c88 and at main cef0eee has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." + }, + "orientation": { + "status": "planned", + "verifiedAt": "2026-10-08", + "note": "PLANNED — maps to the orientation keyword of `@page { size }`. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin a58626c88 and at main cef0eee has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." + }, + "margins": { + "status": "planned", + "verifiedAt": "2026-10-08", + "note": "PLANNED — maps to `@page { margin }`, one side per key, in millimetres. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin a58626c88 and at main cef0eee has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit.", + "children": { + "top": { + "status": "planned", + "verifiedAt": "2026-10-08", + "note": "PLANNED — maps to the top value of `@page { margin }`, in millimetres. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin a58626c88 and at main cef0eee has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." + }, + "right": { + "status": "planned", + "verifiedAt": "2026-10-08", + "note": "PLANNED — maps to the right value of `@page { margin }`, in millimetres. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin a58626c88 and at main cef0eee has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." + }, + "bottom": { + "status": "planned", + "verifiedAt": "2026-10-08", + "note": "PLANNED — maps to the bottom value of `@page { margin }`, in millimetres. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin a58626c88 and at main cef0eee has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." + }, + "left": { + "status": "planned", + "verifiedAt": "2026-10-08", + "note": "PLANNED — maps to the left value of `@page { margin }`, in millimetres. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin a58626c88 and at main cef0eee has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." + } + } + }, + "repeatHeader": { + "status": "planned", + "verifiedAt": "2026-10-08", + "note": "PLANNED — maps to a running header — the page's `header` region repeated on every sheet. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin a58626c88 and at main cef0eee has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." + }, + "repeatFooter": { + "status": "planned", + "verifiedAt": "2026-10-08", + "note": "PLANNED — maps to a running footer — the page's `footer` region repeated on every sheet. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin a58626c88 and at main cef0eee has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." + }, + "pageNumbers": { + "status": "planned", + "verifiedAt": "2026-10-08", + "note": "PLANNED — maps to an `@page` margin box with `counter(page)` and `counter(pages)`. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin a58626c88 and at main cef0eee has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." + }, + "repeatTableHeaders": { + "status": "planned", + "verifiedAt": "2026-10-08", + "note": "PLANNED — maps to `thead { display: table-header-group }`. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin a58626c88 and at main cef0eee has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." + }, + "avoidBreakInside": { + "status": "planned", + "verifiedAt": "2026-10-08", + "note": "PLANNED — maps to `break-inside: avoid` on every block. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin a58626c88 and at main cef0eee has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." + } + } + }, "kind": { "status": "live", "note": "full vs slotted record-page composition — objectui plugin-detail/src/index.tsx (buildDefaultPageSchema)." diff --git a/packages/spec/liveness/state-counts/page.md b/packages/spec/liveness/state-counts/page.md index d2657358ae3..cd180a4fb4c 100644 --- a/packages/spec/liveness/state-counts/page.md +++ b/packages/spec/liveness/state-counts/page.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `page` | 23 | 0 | 0 | 1 | 0 | 24 | +| `page` | 23 | 0 | 0 | 1 | 11 | 35 | diff --git a/packages/spec/src/ui/list-view-export-options.ts b/packages/spec/src/ui/list-view-export-options.ts index 044319e8dd0..73149652077 100644 --- a/packages/spec/src/ui/list-view-export-options.ts +++ b/packages/spec/src/ui/list-view-export-options.ts @@ -33,21 +33,34 @@ import { VIEW_HISTORY } from './view-history'; // `'pdf'` retirement prescription (#8010). Declared with `//` on purpose — the // hook-body precedent's placement note applies here too: build-docs takes a // file's first JSDoc per exported symbol, and this constant needs no doc page. +// +// [#22158] Re-worded under the ruling of record on #8346 (6051470224, letter +// B′, maintainer 「8346 B′」 2026-10-08): the 17.0 retirement STANDS — list +// export does not regain `'pdf'` — but the sentence it carried, "PDF export +// itself was declined as NOT PLANNED", stopped being true when the maintainer +// re-planned PDF documents for v18 (2026-08-13). A printable document is now a +// page with a `print` declaration, so the prescription points there instead, +// and at the view's own `allowPrinting` for printing a list as shown. export const LIST_VIEW_EXPORT_PDF_RETIRED = - "'pdf' was removed from `view.exportOptions` formats in @objectstack/spec 17.0.0 " - + '(PDF export itself was declined as NOT PLANNED) — no renderer has ever produced a PDF ' - + 'export: ObjectGrid dropped the declared format from the export menu with only a runtime ' - + "console.warn, so authoring it was a parse-clean no-op. Delete the value; the surviving " - + "formats are 'csv', 'xlsx' and 'json'. " + "'pdf' was removed from `view.exportOptions` formats in @objectstack/spec 17.0.0 — no " + + 'renderer has ever produced a PDF export: ObjectGrid dropped the declared format from the ' + + 'export menu with only a runtime console.warn, so authoring it was a parse-clean no-op. ' + + "Delete the value; the surviving formats are 'csv', 'xlsx' and 'json'. A list export does " + + "not produce documents: to let users print a list as shown, set the view's `allowPrinting`; " + + 'a printable document (an invoice, a delivery order, a letter) is a page that declares ' + + '`print` — its paper, margins and running header and footer — with its blocks in `regions`. ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.'; /** * Export formats the platform actually delivers (#8010): `csv`/`json` on both * export paths, `xlsx` on the server stream only. * - * `'pdf'` was REMOVED in 17 (#8010): PDF export was declined platform-side - * (#1301 NOT_PLANNED), so the enum member was a declared-but-unrenderable - * format whose only failure signal was a browser console line. This is an + * `'pdf'` was REMOVED in 17 (#8010): PDF export was then declined + * platform-side (#1301 NOT_PLANNED), so the enum member was a + * declared-but-unrenderable format whose only failure signal was a browser + * console line. The removal stands under the 2026-10-08 ruling on #8346 (B′), + * which re-planned PDF documents as print PAGES rather than as a list-export + * format, and the prescription now points there (#22158). This is an * enum-VALUE narrowing, so there is no `retiredKey()` tombstone to hang the * prescription on — the enum's own error map carries it * ({@link LIST_VIEW_EXPORT_PDF_RETIRED}), keyed on `issue.input` so that only diff --git a/packages/spec/src/ui/object-refinement-check-exports.test.ts b/packages/spec/src/ui/object-refinement-check-exports.test.ts index ad6ced460f0..e6ac39d2c6a 100644 --- a/packages/spec/src/ui/object-refinement-check-exports.test.ts +++ b/packages/spec/src/ui/object-refinement-check-exports.test.ts @@ -55,7 +55,7 @@ import { ObjectListViewSchema, checkListViewCalendarVisualization, } from './view.zod'; -import { PageSchema, checkPageSourceCompleteness, checkPageRequiresKind } from './page.zod'; +import { PageSchema, checkPageSourceCompleteness, checkPageRequiresKind, checkPagePrintComposition } from './page.zod'; import { GlobalFilterSchema, checkGlobalFilterDateDefaultValue, @@ -237,6 +237,23 @@ const pageRequiresFixtures: Fixture[] = [ { label: 'a `react` page with no `requires`', value: { ...PAGE_BASE, kind: 'react', source: 'Card' }, refusesAt: [] }, ]; +// A print page prints exactly the blocks it authors (#22158, ruling B′ on +// #8346): `print` is refused on the kinds and types that draw blocks nobody +// authored, and a running header or footer needs the region it repeats. +const PRINT_REGIONS = [ + { name: 'header', components: [{ type: 'element:text' }] }, + { name: 'main', components: [{ type: 'record:details' }] }, +]; +const pagePrintFixtures: Fixture[] = [ + { label: '`print` on a `slotted` page', value: { ...PAGE_BASE, kind: 'slotted', regions: PRINT_REGIONS, print: {} }, refusesAt: ['print'] }, + { label: '`print` on an `html` page with a `source`', value: { ...PAGE_BASE, kind: 'html', source: 'Card', regions: PRINT_REGIONS, print: {} }, refusesAt: ['print'] }, + { label: '`print` on a `list` page', value: { ...PAGE_BASE, type: 'list', regions: PRINT_REGIONS, print: {} }, refusesAt: ['print'] }, + { label: '`print` on a `full` page with no `regions`', value: { ...PAGE_BASE, print: {} }, refusesAt: ['print'] }, + { label: '`print.repeatFooter` with no `footer` region', value: { ...PAGE_BASE, regions: PRINT_REGIONS, print: { repeatHeader: true, repeatFooter: true } }, refusesAt: ['print.repeatFooter'] }, + { label: '`print` on a `full` page with `regions` and its `header` region', value: { ...PAGE_BASE, regions: PRINT_REGIONS, print: { repeatHeader: true } }, refusesAt: [] }, + { label: 'a `slotted` page with no `print`', value: { ...PAGE_BASE, kind: 'slotted' }, refusesAt: [] }, +]; + const DATE_FILTER = { field: 'created_at', type: 'date' } as const; const dateDefaultFixtures: Fixture[] = [ @@ -449,6 +466,7 @@ const MIRRORED: MirroredSchema[] = [ exports: [ { name: 'checkPageSourceCompleteness', check: checkPageSourceCompleteness, fixtures: pageSourceFixtures }, { name: 'checkPageRequiresKind', check: checkPageRequiresKind, fixtures: pageRequiresFixtures }, + { name: 'checkPagePrintComposition', check: checkPagePrintComposition, fixtures: pagePrintFixtures }, ], cleanFixtures: [{ ...PAGE_BASE }], }, @@ -596,9 +614,9 @@ describe('each schema attaches its export BY IDENTIFIER — no inline copy', () expect(attachments(src, 'checkListViewPageMount')).toBe(0); }); - it('page.zod.ts declares both exports and attaches each to PageSchema', () => { + it('page.zod.ts declares all three exports and attaches each to PageSchema', () => { const src = read('page.zod.ts'); - for (const name of ['checkPageSourceCompleteness', 'checkPageRequiresKind']) { + for (const name of ['checkPageSourceCompleteness', 'checkPageRequiresKind', 'checkPagePrintComposition']) { expect(src).toContain(`export function ${name}(`); expect(declarations(src, name)).toBe(1); expect(attachments(src, name)).toBe(1); @@ -630,6 +648,7 @@ describe('`./index` (the `@objectstack/spec/ui` surface) exports the same functi ['checkListViewCalendarVisualization', checkListViewCalendarVisualization], ['checkPageSourceCompleteness', checkPageSourceCompleteness], ['checkPageRequiresKind', checkPageRequiresKind], + ['checkPagePrintComposition', checkPagePrintComposition], ['checkGlobalFilterDateDefaultValue', checkGlobalFilterDateDefaultValue], ] as const)('%s — reference identity, and the `(value, ctx)` arity', (name, fn) => { expect((ui as Record)[name]).toBe(fn); diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 79049436bda..7e14db4c2be 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -3906,7 +3906,10 @@ describe('ListViewSchema — retired striped/bordered/virtualScroll (every reade // and functional. The object form declares exactly the five renderer-read keys // (measured objectui origin/main@878140b, ObjectGrid.tsx:1596–1642); the // legacy bare array stays accepted and lifts to `{ formats }` at parse. -// `'pdf'` left the enum in the same change (#1301 NOT_PLANNED). +// `'pdf'` left the enum in the same change (#1301 NOT_PLANNED). The removal +// stands under the 2026-10-08 ruling on #8346 (B′), which re-planned PDF +// documents as print PAGES; the prescription stopped saying "declined as NOT +// PLANNED" and points at the print page instead (#22158). // ============================================================================ describe('ListViewSchema.exportOptions — object form + array lift + pdf retirement', () => { it('accepts the object form with all five renderer-read keys, byte-preserved (no unrecognized_keys)', () => { @@ -3934,7 +3937,12 @@ describe('ListViewSchema.exportOptions — object form + array lift + pdf retire ListViewSchema.parse({ type: 'grid', columns: ['name'], exportOptions: ['xlsx', 'pdf'] }); } catch (e) { message = String((e as Error).message); } expect(message).toMatch(/'pdf' was removed from `view\.exportOptions` formats/); - expect(message).toMatch(/PDF export itself was declined as NOT PLANNED/); + // [#22158] The sentence that stopped being true is gone, and the + // prescription points at the two live answers instead: the view's own + // print control, and a page that declares `print` for a document. + expect(message).not.toMatch(/NOT PLANNED|declined/); + expect(message).toMatch(/set the view's `allowPrinting`/); + expect(message).toMatch(/a printable document \(an invoice, a delivery order, a letter\) is a page that declares\s+`print`/); expect(message).not.toMatch(/#\d{3,5}\b/); expect(message).toMatch(/'csv', 'xlsx' and 'json'/); expect(message).toMatch(/Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand\./); @@ -3943,7 +3951,7 @@ describe('ListViewSchema.exportOptions — object form + array lift + pdf retire it("REJECTS 'pdf' in the object form's `formats` with the same prescription", () => { expect(() => ListViewSchema.parse({ type: 'grid', columns: ['name'], exportOptions: { formats: ['csv', 'pdf'] }, - })).toThrow(/'pdf' was removed from `view\.exportOptions` formats.*NOT PLANNED/s); + })).toThrow(/'pdf' was removed from `view\.exportOptions` formats.*a page that declares\s+`print`/s); }); it('a wrong format that was NEVER legal keeps the plain enum message, not the retirement text', () => { From 864eccb288db948e0119b65ee6c2b79cd2393d44 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 03:53:08 +0000 Subject: [PATCH 03/15] feat(spec)!: retire the zero-reader document family whole (DocumentTemplate, Document, ESignatureConfig, DocumentVersion) ADR-0049 enforce-or-remove, by the B-prime ruling on #8346: a printable document is a page that declares print, so 'template' means one thing. Four defs leave @objectstack/spec/data via RETIRED_DEFS_BY_MAJOR[18] with the D3 semantic entry document-schemas-retired and a step-18 rationale fragment; the ESignatureConfig deadline-key entries stay as history. Generated artifacts follow in the next commit. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- packages/spec/PROTOCOL_MAP.md | 1 - packages/spec/llms.txt | 4 +- .../spec/scripts/file-description.test.ts | 9 +- .../data/document-schemas-retirement.test.ts | 229 +++++++ packages/spec/src/data/document.test.ts | 623 ------------------ packages/spec/src/data/document.zod.ts | 424 ------------ ...signature-deadline-keys-retirement.test.ts | 235 ------- packages/spec/src/data/index.ts | 16 +- .../entries/retired-defs/18.data__Document.ts | 21 + .../retired-defs/18.data__DocumentTemplate.ts | 22 + .../retired-defs/18.data__DocumentVersion.ts | 22 + .../retired-defs/18.data__ESignatureConfig.ts | 24 + .../semantic/18.document-schemas-retired.ts | 62 ++ packages/spec/src/migrations/registry.ts | 154 +++++ packages/spec/vitest.repo-tests.json | 1 + 15 files changed, 556 insertions(+), 1291 deletions(-) create mode 100644 packages/spec/src/data/document-schemas-retirement.test.ts delete mode 100644 packages/spec/src/data/document.test.ts delete mode 100644 packages/spec/src/data/document.zod.ts delete mode 100644 packages/spec/src/data/esignature-deadline-keys-retirement.test.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/18.data__Document.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/18.data__DocumentTemplate.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/18.data__DocumentVersion.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/18.data__ESignatureConfig.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.document-schemas-retired.ts diff --git a/packages/spec/PROTOCOL_MAP.md b/packages/spec/PROTOCOL_MAP.md index 0da7fd319dc..cb72ba89603 100644 --- a/packages/spec/PROTOCOL_MAP.md +++ b/packages/spec/PROTOCOL_MAP.md @@ -33,7 +33,6 @@ This document serves as the **Grand Map** of the ObjectStack specification. It l | [`seed.zod.ts`](src/data/seed.zod.ts) | | **Seed**. Seed data / fixtures — bootstrap, reference, and demo rows applied on publish. (Was `dataset` until #1620; the `dataset` name now belongs to the analytics semantic layer.) | | [`seed-loader.zod.ts`](src/data/seed-loader.zod.ts) | | **Seed Loader**. How seed rows are resolved and applied. | | [`analytics.zod.ts`](src/data/analytics.zod.ts) | | **Data Analytics**. Aggregation and multidimensional analysis types. | -| [`document.zod.ts`](src/data/document.zod.ts) | | **Document**. Unstructured document storage protocol. | | [`filter.zod.ts`](src/data/filter.zod.ts) | | **Filter**. Low-level filter syntax definitions. | | [`hook.zod.ts`](src/data/hook.zod.ts) | | **Triggers/Hooks**. Database trigger definitions (before/after insert/update). | | [`mapping.zod.ts`](src/data/mapping.zod.ts) | | **Data Mapping**. Rules for transforming data between schemas. | diff --git a/packages/spec/llms.txt b/packages/spec/llms.txt index e3b22769d8a..0b13a26fe4a 100644 --- a/packages/spec/llms.txt +++ b/packages/spec/llms.txt @@ -77,7 +77,7 @@ const query = { --- -## 3. Schema Inventory by Domain (203 schemas) +## 3. Schema Inventory by Domain (202 schemas) Counted as `*.zod.ts` modules under `packages/spec/src//` — the sources that ship in this tarball (`files` includes `src/**/*.zod.ts`), so every number @@ -87,7 +87,7 @@ here is verifiable from the installed package. |--------|-------|-------------| | system | 34 | Auth, Cache, Compliance, Dev Login, Encryption, HTTP Server, License, Logging, Metrics | | kernel | 31 | Plugin, Manifest, Events (6 sub-modules), Feature, Context, Package Registry | -| data | 31 | Object, Field, Picklist, Query, Filter, Driver (SQL/NoSQL/Memory/Mongo/Postgres), Cube | +| data | 30 | Object, Field, Picklist, Query, Filter, Driver (SQL/NoSQL/Memory/Mongo/Postgres), Cube | | api | 31 | Endpoint, REST Server, Discovery, OData, Batch, WebSocket, Response Envelope, Package Lifecycle, Package API (assembled stage) | | ui | 18 | View, App, Action, Dashboard, Page, Chart, Component, Animation | | automation | 13 | Flow, Approval, BPMN Interop, Control Flow, Webhook, Schedule Organization | diff --git a/packages/spec/scripts/file-description.test.ts b/packages/spec/scripts/file-description.test.ts index 45a652ea58c..1ff07334fd6 100644 --- a/packages/spec/scripts/file-description.test.ts +++ b/packages/spec/scripts/file-description.test.ts @@ -292,19 +292,20 @@ describe('findModuleDocBlock — #13263: an import injected between a block and }); it('rejects it across a MULTI-LINE import — continuation lines are plumbing too', () => { - // `data/document.zod.ts` and `kernel/execution-context.zod.ts` reach their - // declaration only over a wrapped import's `Foo,` and `} from '…';` lines. + // `kernel/execution-context.zod.ts` reaches its declaration only over a + // wrapped import's `Foo,` and `} from '…';` lines (and so did the since-retired + // `data/document.zod.ts`). const source = [ "import { z } from 'zod';", '', '/**', - ' * Document Version Schema', + ' * Execution Context Schema', ' */', 'import {', ' MetadataProtectionFields,', ' ProtectionSchema,', "} from '../kernel/metadata-protection.zod';", - 'export const DocumentVersionSchema = z.object({});', + 'export const ExecutionContextSchema = z.object({});', '', ].join('\n'); expect(findModuleDocBlock(source)).toBeNull(); diff --git a/packages/spec/src/data/document-schemas-retirement.test.ts b/packages/spec/src/data/document-schemas-retirement.test.ts new file mode 100644 index 00000000000..7be46b514f4 --- /dev/null +++ b/packages/spec/src/data/document-schemas-retirement.test.ts @@ -0,0 +1,229 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The document family — `data/document.zod.ts`'s `DocumentTemplateSchema`, + * `DocumentSchema`, `ESignatureConfigSchema` and the orphaned + * `DocumentVersionSchema` — RETIRED whole (#22158, ADR-0049 enforce-or-remove), + * by the ruling of record on #8346 (letter B′, maintainer 「8346 B′」 + * 2026-10-08): "A document is a page with a print declaration; no new template + * type", and "The zero-reader `DocumentTemplateSchema`, `DocumentSchema` and + * `ESignatureConfigSchema` retire in v18 under ADR-0049 with ADR-0087 entries, + * so that 'template' means one thing." A printable document is now a page that + * declares `print` (`ui/page.zod.ts`). + * + * Zero readers, measured before the removal (counts plus the tree each was + * taken against): objectstack `fec87e7e0` — every hit for the family's names + * outside `packages/spec` was generated reference docs, release notes or a + * changelog (control: 148 files outside spec name `FieldSchema`); objectui at + * the `.objectui-sha` pin a58626c88 and at main cef0eee — 0 (control: 55 files + * name `PageSchema`); hotcrm 1e88edc — 0 (control: 77 files name + * `defineStack` / `ObjectSchema`). + * + * Bookkeeping shapes, pinned below: + * 1. No carrier key, so no `retiredKey()` tombstone and no D2 conversion: none + * of these schemas is a stack collection member or a metadata type, so a + * conversion would have no seam that runs. + * 2. `RETIRED_DEFS_BY_MAJOR[18]` carries the four published defs, and the D3 + * entry `document-schemas-retired` carries the judgement an upgrader owes. + * 3. The `ESignatureConfig` deadline-key entries in `RETIRED_KEYS_BY_MAJOR[18]` + * stay as history (gate (b2) accepts an entry naming a key the build no + * longer emits). + * 4. The family's exports are gone from `@objectstack/spec/data` and from the + * root `Data` namespace, while the unrelated `DocumentSchemaValidation` + * survives (the lit control). + * 5. Tree-scoped absence: nothing inside the declared radius still imports a + * retired export from a spec specifier or reaches one through `Data.`. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import * as data from './index'; +import { Data } from '../index'; +import { + MIGRATIONS_BY_MAJOR, + RETIRED_DEFS_BY_MAJOR, + RETIRED_KEYS_BY_MAJOR, +} from '../migrations/registry'; + +const RETIRED_DEFS = ['data/Document', 'data/DocumentTemplate', 'data/DocumentVersion', 'data/ESignatureConfig']; +const RETIRED_VALUE_EXPORTS = ['DocumentSchema', 'DocumentTemplateSchema', 'DocumentVersionSchema', 'ESignatureConfigSchema']; + +describe('the document family is retired whole, and registered', () => { + it('RETIRED_DEFS_BY_MAJOR[18] carries the four published defs', () => { + for (const def of RETIRED_DEFS) expect(RETIRED_DEFS_BY_MAJOR[18], def).toContain(def); + }); + + it('the D3 entry `document-schemas-retired` names the print page as the replacement', () => { + const entry = MIGRATIONS_BY_MAJOR[18]!.semantic.find((s) => s.id === 'document-schemas-retired'); + expect(entry).toBeDefined(); + expect(entry!.replacement).toMatch(/a PAGE that declares `print`/); + expect(entry!.reason).toMatch(/ADR-0049/); + expect(entry!.acceptanceCriteria).toMatch(/TS2305/); + // No conversion judged: there is no seam a conversion could run on. + expect(entry!.conversionIds).toBeUndefined(); + }); + + it('the step-18 rationale tells the upgrader the family left', () => { + expect(MIGRATIONS_BY_MAJOR[18]!.rationale).toMatch(/retires the document family WHOLE/); + }); + + it('the ESignatureConfig deadline-key entries stay registered as history', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('data/ESignatureConfig:expirationDays'); + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('data/ESignatureConfig:reminderDays'); + }); +}); + +describe('the family\'s exports are gone from every entry that carried them', () => { + it.each(RETIRED_VALUE_EXPORTS)('`%s` is exported neither from `@objectstack/spec/data` nor through `Data`', (name) => { + expect(Object.keys(data)).not.toContain(name); + expect(Object.keys(Data)).not.toContain(name); + }); + + it('the unrelated `DocumentSchemaValidationSchema` (the NoSQL driver block) survives — the lit control', () => { + expect(Object.keys(data)).toContain('DocumentSchemaValidationSchema'); + expect(Object.keys(Data)).toContain('DocumentSchemaValidationSchema'); + }); +}); + +// ─── Tree-scoped absence, with a DECLARED radius ───────────────────────────── +// +// `tsc` is the primary sweeper — the family's exports are gone, so every typed +// import fails to compile (TS2305). The residue is what `tsc` never judges: +// MD/MDX code fences, JSON, YAML and untyped `.js`. This walk covers that +// residue across the five repo roots `scripts/cross-package-test-inputs.mjs` +// declares for `@objectstack/spec#test` (mirrored in `turbo.json`), plus the +// example apps' own `src/` trees, declared there as `examples/*/src/**/*.ts`. +// +// Two matchers, each judging a USE, never a mention: +// - an `import` / `export … from` naming a retired export from an +// `@objectstack/spec` specifier — the type aliases included, since inside a +// spec import even the generic `Document` names the retired type; +// - a `Data.` namespace access. +// Inline code is prose and is stripped before judging. The bound, stated: +// `docs/**`, `.claude/**`, `.github/**` and the repo-root files are outside what +// this walk sees. +describe('tree-scoped absence: nothing inside the declared radius still imports the document family', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.tsx', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Under `examples/` the non-code extensions, plus `.ts` inside an app's own `src/` tree. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + const RETIRED_NAMES = + '(DocumentTemplateSchema|DocumentSchema|ESignatureConfigSchema|DocumentVersionSchema' + + '|DocumentTemplateParsed|DocumentTemplate|ESignatureConfigParsed|ESignatureConfig' + + '|DocumentVersionParsed|DocumentVersion|DocumentParsed|Document)'; + const USES = [ + // An import or re-export naming a retired export from a spec specifier. + new RegExp(`\\b(import|export)\\s+(type\\s+)?\\{[^}]*\\b${RETIRED_NAMES}\\b[^}]*\\}\\s*from\\s*['"]@objectstack/spec`, 'm'), + // A namespace access through the root `Data` export. + new RegExp('\\bData\\.(DocumentTemplateSchema|DocumentSchema|ESignatureConfigSchema|DocumentVersionSchema)\\b', 'm'), + ]; + + /** + * Inline code spans are prose — the house style `check:doc-authoring` enforces + * — so stripping single-backtick spans separates "the retirement kit naming + * what it removed" from "a source still using it". Newline-bounded: a fenced + * block's content is NOT stripped. + */ + const stripInlineCode = (text: string): string => text.replace(/`[^`\n]*`/g, ''); + const judge = (text: string): RegExpExecArray | null => { + const stripped = stripInlineCode(text); + for (const re of USES) { + const m = re.exec(stripped); + if (m) return m; + } + return null; + }; + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4). + */ + const EXCLUDED = new Set([ + // This pin spells the retired names to assert their absence. + THIS_FILE, + ]); + const EXCLUDED_PREFIXES = [ + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + // GITIGNORED build output (`packages/spec/json-schema/`), reached only + // because this is a FILESYSTEM walk. Its source is the Zod tree. + 'packages/spec/json-schema/', + ]; + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + /** Tolerates ONLY a path that vanished mid-walk; every other read fault is re-raised. */ + const readIfPresent = (full: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + return undefined; + } + }; + + it('the matcher recognises a use and ignores a prose mention and the neighbours (anti-vacuity)', () => { + // Uses — in each syntax the walk reads. + expect(judge("import { DocumentTemplateSchema } from '@objectstack/spec/data';")).not.toBeNull(); + expect(judge("import type { Document, DocumentVersion } from '@objectstack/spec/data';")).not.toBeNull(); + expect(judge("Prose.\n\n```ts\nimport { ESignatureConfigSchema } from '@objectstack/spec';\n```\n")).not.toBeNull(); + expect(judge("export { DocumentSchema } from '@objectstack/spec/data';")).not.toBeNull(); + expect(judge('const t = Data.DocumentTemplateSchema.parse(x);')).not.toBeNull(); + // Neighbours that must NOT match. + expect(judge('the `DocumentTemplateSchema` export was retired')).toBeNull(); + expect(judge("import { DocumentSchemaValidationSchema } from '@objectstack/spec/data';")).toBeNull(); + expect(judge("import { KnowledgeDocumentSchema } from '@objectstack/spec/ai';")).toBeNull(); + expect(judge("import { DocumentTemplateSchema } from './local-template';")).toBeNull(); + expect(judge('const doc = document.createElement("div");')).toBeNull(); + }); + + it('no use of a retired export survives inside the declared radius outside the retirement kit', () => { + const offenders: string[] = []; + let visited = 0; + let exampleSources = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + const scanned = rel.startsWith('examples/') + ? EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel) + : SCANNED_EXT.has(ext); + if (!scanned) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + if (EXAMPLE_APP_SRC_TS.test(rel)) exampleSources += 1; + const text = readIfPresent(full); + if (text === undefined) continue; + const m = judge(text); + if (m) offenders.push(`${rel} uses \`${m[0].trim().replace(/\s+/g, ' ')}\``); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk really covered the tree, and the example apps' + // sources were really read. + expect(visited).toBeGreaterThan(1000); + expect(exampleSources).toBeGreaterThan(50); + expect(offenders, 'a use of a retired document export means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/data/document.test.ts b/packages/spec/src/data/document.test.ts deleted file mode 100644 index 22352894c40..00000000000 --- a/packages/spec/src/data/document.test.ts +++ /dev/null @@ -1,623 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - DocumentVersionSchema, - DocumentTemplateSchema, - ESignatureConfigSchema, - DocumentSchema, - type Document, - type DocumentVersion, - type DocumentTemplate, - type ESignatureConfig, -} from './document.zod'; - -describe('DocumentVersionSchema', () => { - it('should validate complete document version', () => { - const validVersion: DocumentVersion = { - versionNumber: 2, - createdAt: 1704067200000, - createdBy: 'user_123', - size: 2048576, - checksum: 'a1b2c3d4e5f6', - downloadUrl: 'https://storage.example.com/docs/v2/file.pdf', - isLatest: true, - }; - - expect(() => DocumentVersionSchema.parse(validVersion)).not.toThrow(); - }); - - it('should accept minimal version', () => { - const minimalVersion = { - versionNumber: 1, - createdAt: Date.now(), - createdBy: 'user_456', - size: 1024, - checksum: 'checksum123', - downloadUrl: 'https://example.com/file.pdf', - }; - - expect(() => DocumentVersionSchema.parse(minimalVersion)).not.toThrow(); - }); - - it('should default isLatest to false', () => { - const version = { - versionNumber: 1, - createdAt: Date.now(), - createdBy: 'user_123', - size: 1024, - checksum: 'abc', - downloadUrl: 'https://example.com/file.pdf', - }; - - const parsed = DocumentVersionSchema.parse(version); - expect(parsed.isLatest).toBe(false); - }); - - it('should validate download URL', () => { - const invalidVersion = { - versionNumber: 1, - createdAt: Date.now(), - createdBy: 'user_123', - size: 1024, - checksum: 'abc', - downloadUrl: 'not-a-url', - }; - - expect(() => DocumentVersionSchema.parse(invalidVersion)).toThrow(); - }); -}); - -describe('DocumentTemplateSchema', () => { - it('should validate complete document template', () => { - const validTemplate: DocumentTemplate = { - id: 'contract-template', - name: 'Service Agreement', - description: 'Standard service agreement template', - fileUrl: 'https://example.com/templates/contract.docx', - fileType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - placeholders: [ - { - key: 'client_name', - label: 'Client Name', - type: 'text', - required: true, - }, - { - key: 'contract_date', - label: 'Contract Date', - type: 'date', - required: true, - }, - { - key: 'amount', - label: 'Contract Amount', - type: 'number', - required: false, - }, - ], - }; - - expect(() => DocumentTemplateSchema.parse(validTemplate)).not.toThrow(); - }); - - it('should accept minimal template', () => { - const minimalTemplate = { - id: 'simple-template', - name: 'Simple Template', - fileUrl: 'https://example.com/template.pdf', - fileType: 'application/pdf', - placeholders: [], - }; - - expect(() => DocumentTemplateSchema.parse(minimalTemplate)).not.toThrow(); - }); - - it('should default placeholder required to false', () => { - const template = { - id: 'template-1', - name: 'Template', - fileUrl: 'https://example.com/template.pdf', - fileType: 'application/pdf', - placeholders: [ - { - key: 'field1', - label: 'Field 1', - type: 'text' as const, - }, - ], - }; - - const parsed = DocumentTemplateSchema.parse(template); - expect(parsed.placeholders[0].required).toBe(false); - }); - - it('should accept all placeholder types', () => { - const types = ['text', 'number', 'date', 'image'] as const; - - types.forEach((type) => { - const template = { - id: `template-${type}`, - name: 'Template', - fileUrl: 'https://example.com/template.pdf', - fileType: 'application/pdf', - placeholders: [ - { - key: 'field', - label: 'Field', - type, - }, - ], - }; - - expect(() => DocumentTemplateSchema.parse(template)).not.toThrow(); - }); - }); - - it('should reject invalid placeholder type', () => { - const invalidTemplate = { - id: 'invalid-template', - name: 'Invalid', - fileUrl: 'https://example.com/template.pdf', - fileType: 'application/pdf', - placeholders: [ - { - key: 'field', - label: 'Field', - type: 'invalid', - }, - ], - }; - - expect(() => DocumentTemplateSchema.parse(invalidTemplate)).toThrow(); - }); -}); - -describe('ESignatureConfigSchema', () => { - it('should validate complete e-signature config', () => { - const validConfig: ESignatureConfig = { - provider: 'docusign', - enabled: true, - signers: [ - { - email: 'client@example.com', - name: 'John Doe', - role: 'Client', - order: 1, - }, - { - email: 'manager@example.com', - name: 'Jane Smith', - role: 'Manager', - order: 2, - }, - ], - }; - - expect(() => ESignatureConfigSchema.parse(validConfig)).not.toThrow(); - }); - - it('should accept minimal e-signature config', () => { - const minimalConfig = { - provider: 'hellosign', - signers: [ - { - email: 'signer@example.com', - name: 'Signer', - role: 'Signer', - order: 1, - }, - ], - }; - - expect(() => ESignatureConfigSchema.parse(minimalConfig)).not.toThrow(); - }); - - it('should default enabled to false', () => { - const config = { - provider: 'adobe-sign', - signers: [ - { - email: 'test@example.com', - name: 'Test', - role: 'Test', - order: 1, - }, - ], - }; - - const parsed = ESignatureConfigSchema.parse(config); - expect(parsed.enabled).toBe(false); - }); - - // `expirationDays` / `reminderDays` were retiredKey() tombstones since #14477 - // (ADR-0049): the two former default pins (30 / 7) pinned exactly the branch - // that left, so they are replaced, not adjusted. The full kit — refusal on - // the base schema and through `DocumentSchema.eSignature`, the no-materialize - // pin, the tsc `never` channel and the ADR-0087 registration — lives in - // `esignature-deadline-keys-retirement.test.ts`; these two are the family - // tests' one-line refusal witnesses (the #14477 house shape). - it('REFUSES an authored `expirationDays` — a retiredKey() tombstone, since nothing ever read it (ADR-0049)', () => { - const wellFormed = { - provider: 'custom', - signers: [{ email: 'test@example.com', name: 'Test', role: 'Test', order: 1 }], - }; - const result = ESignatureConfigSchema.safeParse({ ...wellFormed, expirationDays: 30 }); - expect(result.success).toBe(false); - if (result.success) return; - const issue = result.error.issues.find((i) => i.path.join('.') === 'expirationDays'); - expect(issue?.code).toBe('invalid_type'); - expect(issue?.message).toMatch(/^`ESignatureConfig\.expirationDays` was removed in @objectstack\/spec 17 \(ADR-0049 enforce-or-remove\)/); - // Attribution control: the same config WITHOUT the key parses, and the - // parsed value carries neither the key nor its former default. - const parsed = ESignatureConfigSchema.parse(wellFormed); - expect(parsed).not.toHaveProperty('expirationDays'); - }); - - it('REFUSES an authored `reminderDays` — a retiredKey() tombstone, since nothing ever read it (ADR-0049)', () => { - const wellFormed = { - provider: 'docusign', - signers: [{ email: 'test@example.com', name: 'Test', role: 'Test', order: 1 }], - }; - const result = ESignatureConfigSchema.safeParse({ ...wellFormed, reminderDays: 7 }); - expect(result.success).toBe(false); - if (result.success) return; - const issue = result.error.issues.find((i) => i.path.join('.') === 'reminderDays'); - expect(issue?.code).toBe('invalid_type'); - expect(issue?.message).toMatch(/^`ESignatureConfig\.reminderDays` was removed in @objectstack\/spec 17 \(ADR-0049 enforce-or-remove\)/); - const parsed = ESignatureConfigSchema.parse(wellFormed); - expect(parsed).not.toHaveProperty('reminderDays'); - }); - - it('should accept all provider types', () => { - const providers = ['docusign', 'adobe-sign', 'hellosign', 'custom'] as const; - - providers.forEach((provider) => { - const config = { - provider, - signers: [ - { - email: 'test@example.com', - name: 'Test', - role: 'Test', - order: 1, - }, - ], - }; - - expect(() => ESignatureConfigSchema.parse(config)).not.toThrow(); - }); - }); - - it('should validate signer email addresses', () => { - const invalidConfig = { - provider: 'docusign', - signers: [ - { - email: 'not-an-email', - name: 'Test', - role: 'Test', - order: 1, - }, - ], - }; - - expect(() => ESignatureConfigSchema.parse(invalidConfig)).toThrow(); - }); - - it('should accept multiple signers in order', () => { - const config = { - provider: 'docusign', - signers: [ - { - email: 'first@example.com', - name: 'First Signer', - role: 'Client', - order: 1, - }, - { - email: 'second@example.com', - name: 'Second Signer', - role: 'Vendor', - order: 2, - }, - { - email: 'third@example.com', - name: 'Third Signer', - role: 'Witness', - order: 3, - }, - ], - }; - - expect(() => ESignatureConfigSchema.parse(config)).not.toThrow(); - }); -}); - -describe('DocumentSchema', () => { - it('should validate complete document', () => { - const validDocument: Document = { - id: 'doc_123', - name: 'Service Agreement 2024', - description: 'Annual service agreement', - fileType: 'application/pdf', - fileSize: 1048576, - category: 'contracts', - tags: ['legal', '2024', 'services'], - versioning: { - enabled: true, - versions: [ - { - versionNumber: 1, - createdAt: 1704067200000, - createdBy: 'user_123', - size: 1048576, - checksum: 'abc123', - downloadUrl: 'https://example.com/docs/v1.pdf', - isLatest: true, - }, - ], - majorVersion: 1, - minorVersion: 0, - }, - access: { - isPublic: false, - sharedWith: ['user_456', 'team_789'], - expiresAt: 1735689600000, - }, - metadata: { - author: 'John Doe', - department: 'Legal', - }, - }; - - expect(() => DocumentSchema.parse(validDocument)).not.toThrow(); - }); - - it('should accept minimal document', () => { - const minimalDocument = { - id: 'doc_456', - name: 'Simple Document', - fileType: 'application/pdf', - fileSize: 1024, - }; - - expect(() => DocumentSchema.parse(minimalDocument)).not.toThrow(); - }); - - it('should validate document with template', () => { - const documentWithTemplate = { - id: 'doc_789', - name: 'Generated Contract', - fileType: 'application/pdf', - fileSize: 2048, - template: { - id: 'contract-template', - name: 'Contract Template', - fileUrl: 'https://example.com/template.docx', - fileType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - placeholders: [ - { - key: 'client_name', - label: 'Client Name', - type: 'text' as const, - required: true, - }, - ], - }, - }; - - expect(() => DocumentSchema.parse(documentWithTemplate)).not.toThrow(); - }); - - it('should validate document with e-signature', () => { - const documentWithSignature = { - id: 'doc_101', - name: 'Contract for Signature', - fileType: 'application/pdf', - fileSize: 1536, - eSignature: { - provider: 'docusign' as const, - enabled: true, - signers: [ - { - email: 'client@example.com', - name: 'Client', - role: 'Client', - order: 1, - }, - ], - }, - }; - - expect(() => DocumentSchema.parse(documentWithSignature)).not.toThrow(); - }); - - it('should validate versioning configuration', () => { - const documentWithVersions = { - id: 'doc_202', - name: 'Versioned Document', - fileType: 'application/pdf', - fileSize: 2048, - versioning: { - enabled: true, - versions: [ - { - versionNumber: 1, - createdAt: 1704000000000, - createdBy: 'user_123', - size: 1024, - checksum: 'v1-checksum', - downloadUrl: 'https://example.com/v1.pdf', - isLatest: false, - }, - { - versionNumber: 2, - createdAt: 1704067200000, - createdBy: 'user_456', - size: 2048, - checksum: 'v2-checksum', - downloadUrl: 'https://example.com/v2.pdf', - isLatest: true, - }, - ], - majorVersion: 2, - minorVersion: 0, - }, - }; - - expect(() => DocumentSchema.parse(documentWithVersions)).not.toThrow(); - }); - - it('should default access.isPublic to false', () => { - const document = { - id: 'doc_303', - name: 'Document', - fileType: 'application/pdf', - fileSize: 1024, - access: { - sharedWith: ['user_123'], - }, - }; - - const parsed = DocumentSchema.parse(document); - expect(parsed.access?.isPublic).toBe(false); - }); - - it('should validate document with tags and category', () => { - const document = { - id: 'doc_404', - name: 'Tagged Document', - fileType: 'application/pdf', - fileSize: 1024, - category: 'invoices', - tags: ['2024', 'Q1', 'paid'], - }; - - expect(() => DocumentSchema.parse(document)).not.toThrow(); - }); - - it('should validate document with custom metadata', () => { - const document = { - id: 'doc_505', - name: 'Document with Metadata', - fileType: 'application/pdf', - fileSize: 1024, - metadata: { - author: 'Jane Doe', - department: 'Finance', - projectCode: 'PROJ-2024-001', - customField: 'Custom Value', - }, - }; - - expect(() => DocumentSchema.parse(document)).not.toThrow(); - }); - - it('should validate complete document with all features', () => { - const completeDocument: Document = { - id: 'doc_complete', - name: 'Complete Document Example', - description: 'A fully-featured document with all options', - fileType: 'application/pdf', - fileSize: 5242880, - category: 'legal-contracts', - tags: ['important', 'signed', '2024', 'annual'], - versioning: { - enabled: true, - versions: [ - { - versionNumber: 1, - createdAt: 1704000000000, - createdBy: 'user_001', - size: 5000000, - checksum: 'checksum-v1', - downloadUrl: 'https://storage.example.com/docs/complete-v1.pdf', - isLatest: false, - }, - { - versionNumber: 2, - createdAt: 1704067200000, - createdBy: 'user_002', - size: 5242880, - checksum: 'checksum-v2', - downloadUrl: 'https://storage.example.com/docs/complete-v2.pdf', - isLatest: true, - }, - ], - majorVersion: 2, - minorVersion: 0, - }, - template: { - id: 'annual-contract-template', - name: 'Annual Contract Template', - description: 'Standard annual contract', - fileUrl: 'https://example.com/templates/annual-contract.docx', - fileType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - placeholders: [ - { - key: 'company_name', - label: 'Company Name', - type: 'text', - required: true, - }, - { - key: 'contract_value', - label: 'Contract Value', - type: 'number', - required: true, - }, - { - key: 'start_date', - label: 'Start Date', - type: 'date', - required: true, - }, - { - key: 'company_logo', - label: 'Company Logo', - type: 'image', - required: false, - }, - ], - }, - eSignature: { - provider: 'docusign', - enabled: true, - signers: [ - { - email: 'client@company.com', - name: 'John Client', - role: 'Client Representative', - order: 1, - }, - { - email: 'vendor@example.com', - name: 'Jane Vendor', - role: 'Vendor Representative', - order: 2, - }, - { - email: 'legal@example.com', - name: 'Legal Counsel', - role: 'Legal Reviewer', - order: 3, - }, - ], - }, - access: { - isPublic: false, - sharedWith: ['user_001', 'user_002', 'team_legal', 'team_finance'], - expiresAt: 1767225600000, // Future date - }, - metadata: { - author: 'Legal Department', - department: 'Legal', - projectCode: 'PROJ-2024-ANNUAL', - confidentialityLevel: 'High', - retentionYears: 7, - complianceStandards: ['SOX', 'GDPR'], - }, - }; - - expect(() => DocumentSchema.parse(completeDocument)).not.toThrow(); - }); -}); diff --git a/packages/spec/src/data/document.zod.ts b/packages/spec/src/data/document.zod.ts deleted file mode 100644 index 5f70b8be892..00000000000 --- a/packages/spec/src/data/document.zod.ts +++ /dev/null @@ -1,424 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { z } from 'zod'; -import { retiredKey } from '../shared/retired-key'; -import { EpochMs } from '../shared/epoch.zod'; - -/** - * Document Version Schema - * - * Represents a single version of a document in a version-controlled system. - * Each version is immutable and maintains its own metadata and download URL. - * - * @example - * ```json - * { - * "versionNumber": 2, - * "createdAt": 1704067200000, - * "createdBy": "user_123", - * "size": 2048576, - * "checksum": "a1b2c3d4e5f6", - * "downloadUrl": "https://storage.example.com/docs/v2/file.pdf", - * "isLatest": true - * } - * ``` - */ -import { lazySchema } from '../shared/lazy-schema'; -export const DocumentVersionSchema = lazySchema(() => z.object({ - /** - * Sequential version number (increments with each new version) - */ - versionNumber: z.number().describe('Version number'), - - /** - * Timestamp when this version was created (Unix milliseconds) - */ - createdAt: EpochMs.describe('Creation timestamp'), - - /** - * User ID who created this version - */ - createdBy: z.string().describe('Creator user ID'), - - /** - * File size in bytes - */ - size: z.number().describe('File size in bytes'), - - /** - * Checksum/hash of the file content (for integrity verification) - */ - checksum: z.string().describe('File checksum'), - - /** - * URL to download this specific version - */ - downloadUrl: z.string().url().describe('Download URL'), - - /** - * Whether this is the latest version - * @default false - */ - isLatest: z.boolean().optional().default(false).describe('Is latest version'), -})); - -/** - * Document Template Schema - * - * Defines a reusable document template with dynamic placeholders. - * Templates can be used to generate documents with variable content. - * - * @example - * ```json - * { - * "id": "contract-template", - * "name": "Service Agreement", - * "description": "Standard service agreement template", - * "fileUrl": "https://example.com/templates/contract.docx", - * "fileType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - * "placeholders": [ - * { - * "key": "client_name", - * "label": "Client Name", - * "type": "text", - * "required": true - * }, - * { - * "key": "contract_date", - * "label": "Contract Date", - * "type": "date", - * "required": true - * } - * ] - * } - * ``` - */ -export const DocumentTemplateSchema = lazySchema(() => z.object({ - /** - * Unique identifier for the template - */ - id: z.string().describe('Template ID'), - - /** - * Human-readable name of the template - */ - name: z.string().describe('Template name'), - - /** - * Optional description of the template's purpose - */ - description: z.string().optional().describe('Template description'), - - /** - * URL to the template file - */ - fileUrl: z.string().url().describe('Template file URL'), - - /** - * MIME type of the template file - */ - fileType: z.string().describe('File MIME type'), - - /** - * List of dynamic placeholders in the template - */ - placeholders: z.array(z.object({ - /** - * Placeholder identifier (used in template) - */ - key: z.string().describe('Placeholder key'), - - /** - * Human-readable label for the placeholder - */ - label: z.string().describe('Placeholder label'), - - /** - * Data type of the placeholder value - */ - type: z.enum(['text', 'number', 'date', 'image']).describe('Placeholder type'), - - /** - * Whether this placeholder must be filled - * @default false - */ - required: z.boolean().optional().default(false).describe('Is required'), - })).describe('Template placeholders'), -})); - -// ─── RETIRED deadline keys (ADR-0049 enforce-or-remove) ───────────────────── -// -// Two day-shaped keys were declared on `ESignatureConfigSchema` and read by -// NOTHING: no e-signature engine exists on the platform — no layer ever sent, -// expired or reminded a signature request — and the reader census over every -// package outside `packages/spec` (tests and changelogs excluded), over -// `examples/**` and `skills/**`, and over objectui at the pinned sha returned -// zero hits for `expirationDays`, `reminderDays`, `eSignature` and the -// `ESignatureConfig` names, with a lit control inside this package. An author -// could write `expirationDays: 30` and the platform would never act on it; the -// generated reference docs advertised an expiry nothing kept. The 2026-09-02 -// ruling on #14477 held the pair on one condition (a roadmapped consumer ⇒ -// `[EXPERIMENTAL — not enforced]`); the maintainer answered it on 2026-09-05 -// (no roadmap), so the ruling's own branch resolves to retirement. -// -// Route: `retiredKey()` tombstones, NOT plain deletion — the schema is not -// `.strict()`, so a bare deletion would make zod strip the key in silence, -// replacing an inert declaration with an invisible one (ADR-0104). The -// tombstone is audible in both channels: `tsc` (the input type is `never`) -// and the parse (the prescription is the message). No D2 conversion and no -// `os migrate meta` sentence: `DocumentSchema` is not a stack collection -// member and `document` is no metadata type, so a conversion would be a -// transform with no seam that ever runs (the -// `kernel/MetadataPluginConfig:additionalTypes` precedent). The retirement is -// registered as `RETIRED_KEYS_BY_MAJOR[18]` entries plus the D3 semantic entry -// `esignature-config-deadline-keys-retired`. `provider` / `enabled` / `signers` -// stay, byte-identical. - -const EXPIRATION_DAYS_RETIRED = - '`ESignatureConfig.expirationDays` was removed in @objectstack/spec 17 (ADR-0049 ' - + 'enforce-or-remove) — nothing ever read it: no e-signature engine exists on the platform, ' - + 'so no signature request was ever sent, expired or lapsed, and its default of 30 days was ' - + 'materialized into every parsed configuration without ever being consulted. Delete the ' - + 'key. There is no replacement, because no e-signature provider integration exists to keep ' - + 'an expiry window.'; - -const REMINDER_DAYS_RETIRED = - '`ESignatureConfig.reminderDays` was removed in @objectstack/spec 17 (ADR-0049 ' - + 'enforce-or-remove) — nothing ever read it: no e-signature engine exists on the platform, ' - + 'so no reminder email was ever sent, and its default of 7 days was materialized into ' - + 'every parsed configuration without ever being consulted. Delete the key. There is no ' - + 'replacement, because no e-signature provider integration exists to send reminders.'; - -/** - * E-Signature Configuration Schema - * - * Configuration for electronic signature workflows. - * Supports integration with popular e-signature providers. - * - * @example - * ```json - * { - * "provider": "docusign", - * "enabled": true, - * "signers": [ - * { - * "email": "client@example.com", - * "name": "John Doe", - * "role": "Client", - * "order": 1 - * }, - * { - * "email": "manager@example.com", - * "name": "Jane Smith", - * "role": "Manager", - * "order": 2 - * } - * ] - * } - * ``` - */ -export const ESignatureConfigSchema = lazySchema(() => z.object({ - /** - * E-signature service provider - */ - provider: z.enum(['docusign', 'adobe-sign', 'hellosign', 'custom']).describe('E-signature provider'), - - /** - * Whether e-signature is enabled for this document - * @default false - */ - enabled: z.boolean().optional().default(false).describe('E-signature enabled'), - - /** - * List of signers in signing order - */ - signers: z.array(z.object({ - /** - * Signer's email address - */ - email: z.string().email().describe('Signer email'), - - /** - * Signer's full name - */ - name: z.string().describe('Signer name'), - - /** - * Signer's role in the document - */ - role: z.string().describe('Signer role'), - - /** - * Signing order (lower numbers sign first) - */ - order: z.number().describe('Signing order'), - })).describe('Document signers'), - - /** - * REMOVED (ADR-0049): `expirationDays` — see the RETIRED section above. - * Authoring it is a `tsc` error and a parse error carrying the prescription. - */ - expirationDays: retiredKey(EXPIRATION_DAYS_RETIRED), - - /** - * REMOVED (ADR-0049): `reminderDays` — see the RETIRED section above. - * Authoring it is a `tsc` error and a parse error carrying the prescription. - */ - reminderDays: retiredKey(REMINDER_DAYS_RETIRED), -})); - -/** - * Document Schema - * - * Comprehensive document management protocol supporting versioning, - * templates, e-signatures, and access control. - * - * @example - * ```json - * { - * "id": "doc_123", - * "name": "Service Agreement 2024", - * "description": "Annual service agreement", - * "fileType": "application/pdf", - * "fileSize": 1048576, - * "category": "contracts", - * "tags": ["legal", "2024", "services"], - * "versioning": { - * "enabled": true, - * "versions": [ - * { - * "versionNumber": 1, - * "createdAt": 1704067200000, - * "createdBy": "user_123", - * "size": 1048576, - * "checksum": "abc123", - * "downloadUrl": "https://example.com/docs/v1.pdf", - * "isLatest": true - * } - * ], - * "majorVersion": 1, - * "minorVersion": 0 - * }, - * "access": { - * "isPublic": false, - * "sharedWith": ["user_456", "team_789"], - * "expiresAt": 1735689600000 - * }, - * "metadata": { - * "author": "John Doe", - * "department": "Legal" - * } - * } - * ``` - */ -export const DocumentSchema = lazySchema(() => z.object({ - /** - * Unique document identifier - */ - id: z.string().describe('Document ID'), - - /** - * Document name - */ - name: z.string().describe('Document name'), - - /** - * Optional document description - */ - description: z.string().optional().describe('Document description'), - - /** - * MIME type of the document - */ - fileType: z.string().describe('File MIME type'), - - /** - * File size in bytes - */ - fileSize: z.number().describe('File size in bytes'), - - /** - * Document category for organization - */ - category: z.string().optional().describe('Document category'), - - /** - * Tags for searchability and organization - */ - tags: z.array(z.string()).optional().describe('Document tags'), - - /** - * Version control configuration - */ - versioning: z.object({ - /** - * Whether versioning is enabled - */ - enabled: z.boolean().describe('Versioning enabled'), - - /** - * List of all document versions - */ - versions: z.array(DocumentVersionSchema).describe('Version history'), - - /** - * Current major version number - */ - majorVersion: z.number().describe('Major version'), - - /** - * Current minor version number - */ - minorVersion: z.number().describe('Minor version'), - }).optional().describe('Version control'), - - /** - * Template configuration (if document is generated from template) - */ - template: DocumentTemplateSchema.optional().describe('Document template'), - - /** - * E-signature configuration - */ - eSignature: ESignatureConfigSchema.optional().describe('E-signature config'), - - /** - * Access control settings - */ - access: z.object({ - /** - * Whether document is publicly accessible - * @default false - */ - isPublic: z.boolean().optional().default(false).describe('Public access'), - - /** - * List of user/team IDs with access - */ - sharedWith: z.array(z.string()).optional().describe('Shared with'), - - /** - * Timestamp when access expires (Unix milliseconds) - */ - expiresAt: EpochMs.optional().describe('Access expiration'), - }).optional().describe('Access control'), - - /** - * Custom metadata fields - */ - metadata: z.record(z.string(), z.unknown()).optional().describe('Custom metadata'), -})); - -// Type exports -export type Document = z.input; -/** Post-parse shape of {@link Document} — defaults applied, transforms run (ADR-0122). */ -export type DocumentParsed = z.infer; -export type DocumentVersion = z.input; -/** Post-parse shape of {@link DocumentVersion} — defaults applied, transforms run (ADR-0122). */ -export type DocumentVersionParsed = z.infer; -export type DocumentTemplate = z.input; -/** Post-parse shape of {@link DocumentTemplate} — defaults applied, transforms run (ADR-0122). */ -export type DocumentTemplateParsed = z.infer; -export type ESignatureConfig = z.input; -/** Post-parse shape of {@link ESignatureConfig} — defaults applied, transforms run (ADR-0122). */ -export type ESignatureConfigParsed = z.infer; diff --git a/packages/spec/src/data/esignature-deadline-keys-retirement.test.ts b/packages/spec/src/data/esignature-deadline-keys-retirement.test.ts deleted file mode 100644 index 3108030a4ad..00000000000 --- a/packages/spec/src/data/esignature-deadline-keys-retirement.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, expect, it } from 'vitest'; -import type { ZodTypeAny } from 'zod'; - -import { DocumentSchema, ESignatureConfigSchema, type Document, type ESignatureConfig } from './document.zod'; -import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; - -// ─── [#14477] the `ESignatureConfig` deadline pair is REMOVED ──────────────── -// -// ADR-0049 enforce-or-remove. The 2026-09-02 ruling on #14477 (ruled A: retire -// per family) held `expirationDays` / `reminderDays` on one condition — a -// roadmapped e-signature consumer would have earned an -// `[EXPERIMENTAL — not enforced]` tag instead — and the maintainer answered it -// on 2026-09-05 (decision batch #40: no roadmap), so the ruling's own branch -// resolves to retirement. Two day-shaped keys on the published authorable -// surface (`data/ESignatureConfig`) and in the generated reference docs, read -// by NOTHING: no e-signature engine exists on the platform — no layer ever -// sent, expired or reminded a signature request — and the reader census over -// every package outside `packages/spec` (tests and changelogs excluded), over -// `examples/**` and `skills/**`, and over objectui at the pinned sha returned -// zero hits for `expirationDays`, `reminderDays`, `eSignature` and the -// `ESignatureConfig` names, with a lit control inside this package. Both -// carried defaults (30 / 7 days) that were materialized into every parsed -// configuration without ever being consulted. -// -// Route: `retiredKey()` tombstones, NOT plain deletion — `ESignatureConfigSchema` -// is not `.strict()`, so a bare deletion would make zod strip the key in -// silence (ADR-0104). Audible in two channels: `tsc` (the input type is -// `never`) and the parse (the prescription is the message). No D2 conversion: -// `DocumentSchema` is not a stack collection member and `document` is no -// metadata type, so the chain has no seam that ever runs (the -// `kernel/MetadataPluginConfig:additionalTypes` precedent) — the registration -// is two `RETIRED_KEYS_BY_MAJOR[18]` entries plus one D3 semantic entry. -// -// On the assertion set (the #8586 / commit 13c48c2a5 / #14477 precedent): a schema -// refusal raises a `ZodError` whose issues carry `code` and `path` but no -// ADR-0112 `status` — that envelope belongs to the API error surface. So these -// pins assert the strongest set this surface really has: refusal, the issue -// `code`, the `path` naming WHICH site refused, and the prescription text -// (#5240: where the wording is the contract, pin the wording). - -// ── Well-formed fixtures: every required key, neither of the retired ones ─── - -const CONFIG: ESignatureConfig = { - provider: 'docusign', - enabled: true, - signers: [{ email: 'client@example.com', name: 'John Doe', role: 'Client', order: 1 }], -}; -const DOCUMENT: Document = { - id: 'doc_101', - name: 'Contract for Signature', - fileType: 'application/pdf', - fileSize: 1536, - eSignature: CONFIG, -}; - -interface RetiredSite { - /** The exact `RETIRED_KEYS_BY_MAJOR` spelling. */ - registered: string; - /** How the prescription opens (its backtick-wrapped qualified key). */ - qualified: string; - schema: ZodTypeAny; - wellFormed: unknown; - authored: unknown; - issuePath: (string | number)[]; - formerDefault: RegExp; -} - -const SITES: RetiredSite[] = [ - { - registered: 'data/ESignatureConfig:expirationDays', - qualified: 'ESignatureConfig.expirationDays', - schema: ESignatureConfigSchema, - wellFormed: CONFIG, - authored: { ...CONFIG, expirationDays: 30 }, - issuePath: ['expirationDays'], - formerDefault: /default of 30 days/, - }, - { - registered: 'data/ESignatureConfig:reminderDays', - qualified: 'ESignatureConfig.reminderDays', - schema: ESignatureConfigSchema, - wellFormed: CONFIG, - authored: { ...CONFIG, reminderDays: 7 }, - issuePath: ['reminderDays'], - formerDefault: /default of 7 days/, - }, -]; - -/** The same two keys through the one carrier that nests the config: `Document.eSignature`. */ -const CARRIERS: Array> = [ - { - qualified: 'ESignatureConfig.expirationDays', - schema: DocumentSchema, - wellFormed: DOCUMENT, - authored: { ...DOCUMENT, eSignature: { ...CONFIG, expirationDays: 15 } }, - issuePath: ['eSignature', 'expirationDays'], - }, - { - qualified: 'ESignatureConfig.reminderDays', - schema: DocumentSchema, - wellFormed: DOCUMENT, - authored: { ...DOCUMENT, eSignature: { ...CONFIG, reminderDays: 3 } }, - issuePath: ['eSignature', 'reminderDays'], - }, -]; - -const SEMANTIC_ID = 'esignature-config-deadline-keys-retired'; - -function escapeRegExp(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function expectTombstoneRefusal(site: Pick) { - const result = site.schema.safeParse(site.authored); - expect(result.success, `${site.qualified} must be refused`).toBe(false); - if (result.success) return; // narrowing; the assertion above already failed - - const wanted = site.issuePath.join('.'); - const issue = result.error.issues.find((i) => i.path.join('.') === wanted); - expect(issue, `the refusal must surface at ${wanted}`).toBeDefined(); - // The machine-readable half of the envelope this surface actually has: a - // `retiredKey()` tombstone raises `invalid_type` from its `z.never()`. - expect(issue!.code).toBe('invalid_type'); - expect(issue!.path).toEqual(site.issuePath); - // The prescription IS the migration doc for whoever hits it — contract, not - // commentary: it opens with the qualified key, names the version and the - // ADR, says why the key was inert, and tells the author what to do. - expect(issue!.message).toMatch( - new RegExp('^`' + escapeRegExp(site.qualified) + '` was removed in @objectstack/spec 17 \\(ADR-0049 enforce-or-remove\\) — nothing ever read it'), - ); - expect(issue!.message).toMatch(/Delete the key/); - expect(issue!.message).toMatch(/no e-signature engine exists/); - // Customer-facing text carries the ADR, never an issue id. - expect(issue!.message).not.toMatch(/#\d{3,}/); - // Deliberately NO `os migrate meta` sentence: no conversion covers this - // schema (not a stack collection member), so the sentence would promise an - // edit list the tool cannot produce (`retired-key.ts`: the sentence must be - // TRUE of the tool). - expect(issue!.message).not.toMatch(/os migrate meta/); -} - -describe('ESignatureConfig deadline pair retirement — refusal at every site', () => { - for (const site of SITES) { - it(`REJECTS an authored \`${site.qualified}\` at path \`${site.issuePath.join('.')}\`, carrying the prescription`, () => { - expectTombstoneRefusal(site); - // Attribution control: the same config WITHOUT the key is accepted, so - // the refusal above is attributable to the retired key and nothing else. - expect(site.schema.safeParse(site.wellFormed).success, `${site.qualified}: well-formed control must parse`).toBe(true); - }); - } - - for (const carrier of CARRIERS) { - it(`REJECTS \`${carrier.qualified}\` through \`Document.eSignature\`, at path \`${carrier.issuePath.join('.')}\``, () => { - expectTombstoneRefusal(carrier); - expect(carrier.schema.safeParse(carrier.wellFormed).success).toBe(true); - }); - } - - it('every prescription names the default it used to materialize', () => { - for (const site of SITES) { - const result = site.schema.safeParse(site.authored); - expect(result.success).toBe(false); - if (result.success) continue; - const issue = result.error.issues.find((i) => i.path.join('.') === site.issuePath.join('.'))!; - expect(issue.message, site.qualified).toMatch(site.formerDefault); - } - }); -}); - -describe('no-materialize: parsed configurations carry neither key and neither former default', () => { - it('on the base schema', () => { - const parsed = ESignatureConfigSchema.parse(CONFIG); - expect(parsed).not.toHaveProperty('expirationDays'); - expect(parsed).not.toHaveProperty('reminderDays'); - // Attribution: the surviving default still materializes, so the absence - // above is the tombstone's doing and not a broken parse. - expect(ESignatureConfigSchema.parse({ provider: 'custom', signers: CONFIG.signers }).enabled).toBe(false); - }); - - it('through `Document.eSignature`', () => { - const parsed = DocumentSchema.parse(DOCUMENT); - expect(parsed.eSignature).toBeDefined(); - expect(parsed.eSignature).not.toHaveProperty('expirationDays'); - expect(parsed.eSignature).not.toHaveProperty('reminderDays'); - }); -}); - -describe('the tsc channel: the input type of both retired keys is `never`', () => { - it('fails tsc at both authoring sites', () => { - const config: ESignatureConfig = { - ...CONFIG, - // @ts-expect-error — `expirationDays` is a retiredKey() tombstone: its input type is `never`. - expirationDays: 30, - // @ts-expect-error — `reminderDays` is a retiredKey() tombstone. - reminderDays: 7, - }; - const document: Document = { - ...DOCUMENT, - eSignature: { - ...CONFIG, - // @ts-expect-error — the tombstone reaches through the carrier. - expirationDays: 15, - }, - }; - // The literals above are typed, so tsc is the assertion; at runtime the - // same values are refused, which keeps this case from being vacuous. - for (const [schema, value] of [ - [ESignatureConfigSchema, config], - [DocumentSchema, document], - ] as Array<[ZodTypeAny, unknown]>) { - expect(schema.safeParse(value).success).toBe(false); - } - }); -}); - -describe('ADR-0087 registration', () => { - it('declares both sites under major 18, with the D3 semantic entry wired and no D2 conversion', () => { - for (const site of SITES) { - expect(RETIRED_KEYS_BY_MAJOR[18], `${site.registered} must be declared`).toContain(site.registered); - } - const step = MIGRATIONS_BY_MAJOR[18]; - expect(step).toBeDefined(); - const entry = step!.semantic.find((s) => s.id === SEMANTIC_ID); - expect(entry, `${SEMANTIC_ID} must be wired into the step-18 chain`).toBeDefined(); - expect(entry!.reason.length).toBeGreaterThan(0); - expect(entry!.acceptanceCriteria.length).toBeGreaterThan(0); - // The route is stated where the next reader looks: why D3 semantic and - // not D2 — no stack seam (the additionalTypes precedent). - expect(entry!.reason).toMatch(/not a D2 conversion/); - // Deliberately no mechanical conversion. - expect(step!.conversionIds.filter((id) => /signature|document/.test(id))).toEqual([]); - }); -}); diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index f9492e5de4e..bb03fe7ec6b 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -228,8 +228,20 @@ export { hookForm } from './hook.form'; // Seed Loader Protocol (Relationship Resolution & Dependency Ordering) export * from './seed-loader.zod'; -// Document Management Protocol -export * from './document.zod'; +// document.zod (DocumentTemplateSchema / DocumentSchema / ESignatureConfigSchema / +// DocumentVersionSchema + every type alias) was REMOVED (#22158) under ADR-0049 +// enforce-or-remove, by the ruling of record on #8346 (letter B′, 2026-10-08): +// "The zero-reader `DocumentTemplateSchema`, `DocumentSchema` and +// `ESignatureConfigSchema` retire in v18 under ADR-0049 with ADR-0087 entries, +// so that 'template' means one thing." A printable document is a page that +// declares `print` (`ui/page.zod.ts`, `PagePrintSchema`) — no second template +// vocabulary. The module declared a docx template with placeholders, a +// document with versioning and access control, and an e-signature workflow, +// and nothing anywhere consumed any of it: no metadata-type binding, no stack +// collection, no reader outside `packages/spec` in this repository, objectui +// or hotcrm. `DocumentVersionSchema` had one carrier, `DocumentSchema.versioning`, +// and left with it (the orphan-value-schema rule). See the D3 record +// `document-schemas-retired`. // external-lookup.zod (ExternalDataSourceSchema / ExternalFieldMappingSchema / // ExternalLookupSchema + every type alias) was REMOVED per ADR-0049 diff --git a/packages/spec/src/migrations/entries/retired-defs/18.data__Document.ts b/packages/spec/src/migrations/entries/retired-defs/18.data__Document.ts new file mode 100644 index 00000000000..4bee44c2620 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.data__Document.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #22158 — `data/Document` — a stored document with versioning, a template, +// an e-signature block and access control, which no document store ever kept — +// leaves whole with the document family under ADR-0049 enforce-or-remove, by +// the ruling of record on #8346 (letter B′, maintainer 「8346 B′」 2026-10-08): +// "The zero-reader `DocumentTemplateSchema`, `DocumentSchema` and +// `ESignatureConfigSchema` retire in v18 under ADR-0049 with ADR-0087 entries, +// so that 'template' means one thing" — a printable document is a page that +// declares `print`. It was exported from `@objectstack/spec/data` +// (`data/document.zod.ts`), mounted by no `stack.zod.ts` key, registered as no +// metadata type, absent from every liveness ledger, and read by NOTHING: on +// objectstack `fec87e7e0` every hit for the family's exported names outside +// `packages/spec` was generated reference docs, release notes or a changelog; +// objectui (the `.objectui-sha` pin a58626c88 and main cef0eee) and hotcrm +// (1e88edc) returned zero, against lit controls on the same pattern. No carrier +// key, so no `retiredKey()` tombstone and no D2 conversion (none of these +// schemas is a stack collection member — the +// `kernel/MetadataPluginConfig:additionalTypes` reasoning): RETIRED_DEFS_BY_MAJOR +// plus the D3 semantic entry `document-schemas-retired` ARE the declaration. +export const entry = 'data/Document'; diff --git a/packages/spec/src/migrations/entries/retired-defs/18.data__DocumentTemplate.ts b/packages/spec/src/migrations/entries/retired-defs/18.data__DocumentTemplate.ts new file mode 100644 index 00000000000..c6f491515a3 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.data__DocumentTemplate.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #22158 — `data/DocumentTemplate` — a docx template URL with typed +// placeholders (`fileUrl` / `fileType` / `placeholders[]`), which no template +// engine ever merged — +// leaves whole with the document family under ADR-0049 enforce-or-remove, by +// the ruling of record on #8346 (letter B′, maintainer 「8346 B′」 2026-10-08): +// "The zero-reader `DocumentTemplateSchema`, `DocumentSchema` and +// `ESignatureConfigSchema` retire in v18 under ADR-0049 with ADR-0087 entries, +// so that 'template' means one thing" — a printable document is a page that +// declares `print`. It was exported from `@objectstack/spec/data` +// (`data/document.zod.ts`), mounted by no `stack.zod.ts` key, registered as no +// metadata type, absent from every liveness ledger, and read by NOTHING: on +// objectstack `fec87e7e0` every hit for the family's exported names outside +// `packages/spec` was generated reference docs, release notes or a changelog; +// objectui (the `.objectui-sha` pin a58626c88 and main cef0eee) and hotcrm +// (1e88edc) returned zero, against lit controls on the same pattern. No carrier +// key, so no `retiredKey()` tombstone and no D2 conversion (none of these +// schemas is a stack collection member — the +// `kernel/MetadataPluginConfig:additionalTypes` reasoning): RETIRED_DEFS_BY_MAJOR +// plus the D3 semantic entry `document-schemas-retired` ARE the declaration. +export const entry = 'data/DocumentTemplate'; diff --git a/packages/spec/src/migrations/entries/retired-defs/18.data__DocumentVersion.ts b/packages/spec/src/migrations/entries/retired-defs/18.data__DocumentVersion.ts new file mode 100644 index 00000000000..83cb6bd3115 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.data__DocumentVersion.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #22158 — `data/DocumentVersion` — one immutable version of a stored +// document, whose only carrier was `DocumentSchema.versioning.versions` (the +// orphan-value-schema rule takes it with its carrier) — +// leaves whole with the document family under ADR-0049 enforce-or-remove, by +// the ruling of record on #8346 (letter B′, maintainer 「8346 B′」 2026-10-08): +// "The zero-reader `DocumentTemplateSchema`, `DocumentSchema` and +// `ESignatureConfigSchema` retire in v18 under ADR-0049 with ADR-0087 entries, +// so that 'template' means one thing" — a printable document is a page that +// declares `print`. It was exported from `@objectstack/spec/data` +// (`data/document.zod.ts`), mounted by no `stack.zod.ts` key, registered as no +// metadata type, absent from every liveness ledger, and read by NOTHING: on +// objectstack `fec87e7e0` every hit for the family's exported names outside +// `packages/spec` was generated reference docs, release notes or a changelog; +// objectui (the `.objectui-sha` pin a58626c88 and main cef0eee) and hotcrm +// (1e88edc) returned zero, against lit controls on the same pattern. No carrier +// key, so no `retiredKey()` tombstone and no D2 conversion (none of these +// schemas is a stack collection member — the +// `kernel/MetadataPluginConfig:additionalTypes` reasoning): RETIRED_DEFS_BY_MAJOR +// plus the D3 semantic entry `document-schemas-retired` ARE the declaration. +export const entry = 'data/DocumentVersion'; diff --git a/packages/spec/src/migrations/entries/retired-defs/18.data__ESignatureConfig.ts b/packages/spec/src/migrations/entries/retired-defs/18.data__ESignatureConfig.ts new file mode 100644 index 00000000000..cc4f6918df8 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.data__ESignatureConfig.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #22158 — `data/ESignatureConfig` — an e-signature workflow (`provider` / +// `enabled` / `signers[]`), which no e-signature integration ever sent. Its +// `RETIRED_KEYS_BY_MAJOR[18]` deadline-key entries (`expirationDays` / +// `reminderDays`, the #14477 tombstones) stay as history — gate (b2) of +// build-schemas.ts accepts an entry naming a key the build no longer emits — +// leaves whole with the document family under ADR-0049 enforce-or-remove, by +// the ruling of record on #8346 (letter B′, maintainer 「8346 B′」 2026-10-08): +// "The zero-reader `DocumentTemplateSchema`, `DocumentSchema` and +// `ESignatureConfigSchema` retire in v18 under ADR-0049 with ADR-0087 entries, +// so that 'template' means one thing" — a printable document is a page that +// declares `print`. It was exported from `@objectstack/spec/data` +// (`data/document.zod.ts`), mounted by no `stack.zod.ts` key, registered as no +// metadata type, absent from every liveness ledger, and read by NOTHING: on +// objectstack `fec87e7e0` every hit for the family's exported names outside +// `packages/spec` was generated reference docs, release notes or a changelog; +// objectui (the `.objectui-sha` pin a58626c88 and main cef0eee) and hotcrm +// (1e88edc) returned zero, against lit controls on the same pattern. No carrier +// key, so no `retiredKey()` tombstone and no D2 conversion (none of these +// schemas is a stack collection member — the +// `kernel/MetadataPluginConfig:additionalTypes` reasoning): RETIRED_DEFS_BY_MAJOR +// plus the D3 semantic entry `document-schemas-retired` ARE the declaration. +export const entry = 'data/ESignatureConfig'; diff --git a/packages/spec/src/migrations/entries/semantic/18.document-schemas-retired.ts b/packages/spec/src/migrations/entries/semantic/18.document-schemas-retired.ts new file mode 100644 index 00000000000..5cb798447bf --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.document-schemas-retired.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'document-schemas-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'the document family, retired whole: the four defs data/DocumentTemplate, data/Document, ' + + 'data/ESignatureConfig and data/DocumentVersion, and every name data/document.zod.ts ' + + 'exported from @objectstack/spec/data (DocumentTemplateSchema, DocumentSchema, ' + + 'ESignatureConfigSchema, DocumentVersionSchema, their z.input aliases and their Parsed ' + + 'aliases)', + replacement: + 'a printable document is a PAGE that declares `print` — no separate template type. Author ' + + 'the document (an invoice, a delivery order, a letter, a report) as an ordinary `page` ' + + 'with `kind: \'full\'`, its blocks in `regions` drawn from the printable block subset ' + + '(`record:details`, `record:highlights`, `record:line_items`, `element:text`, ' + + '`element:image` and the rest of PRINTABLE_PAGE_COMPONENT_TYPES), and a `print` block for ' + + 'the paper, margins, running header and footer, page numbers and page-break hints. A ' + + 'docx-with-placeholders template, a stored document with versions, and an e-signature ' + + 'workflow have no replacement, because nothing on the platform ever merged, stored or ' + + 'sent any of them; a document record the organisation keeps is ordinary object data, and ' + + 'its files are `sys_file` attachments', + reason: + 'ADR-0049 enforce-or-remove, by the ruling of record on the PDF / print document card ' + + '(letter B′, 2026-10-08): "A document is a page with a print declaration; no new template ' + + 'type", and "The zero-reader DocumentTemplateSchema, DocumentSchema and ' + + 'ESignatureConfigSchema retire in v18 under ADR-0049 with ADR-0087 entries, so that ' + + '\'template\' means one thing." Four defs sat on the exported surface and in the generated ' + + 'reference docs — a docx template with typed placeholders, a document with versioning, ' + + 'access control and an e-signature block, and the signer workflow — and were read by ' + + 'NOTHING: they were exported from `@objectstack/spec/data`, mounted by no `stack.zod.ts` ' + + 'key, registered as no metadata type and absent from every liveness ledger, and the ' + + 'reader census over every package, app and example outside `packages/spec` (generated ' + + 'reference docs, release notes and changelogs aside), over objectui at its pin and its ' + + 'main, and over hotcrm returned zero hits for every exported name, against lit controls. ' + + 'Keeping them would have given an author two meanings of "template" — the dead docx one ' + + 'and the print page — and an AI that imports DocumentTemplateSchema a schema no runtime ' + + 'reads. DocumentVersionSchema had one carrier, DocumentSchema.versioning, and leaves with ' + + 'it. The ESignatureConfig deadline-key tombstones (RETIRED_KEYS_BY_MAJOR[18], D3 ' + + '`esignature-config-deadline-keys-retired`) leave with their def\'s source; their registry ' + + 'entries stay as history. Why D3 semantic and not a D2 conversion: the chain walks a ' + + 'normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its ' + + 'collections; none of these schemas is either, so a conversion would be a transform with no ' + + 'seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent), and ' + + 'with no carrier key there is no shape on which a tombstone could sit. `cloud` and real ' + + 'customer code are UNMEASURED.', + acceptanceCriteria: + 'No code imports DocumentTemplateSchema, DocumentSchema, ESignatureConfigSchema or ' + + 'DocumentVersionSchema — or any of their type aliases — from @objectstack/spec or ' + + '@objectstack/spec/data: every such import is TS2305 after upgrade. A printable document ' + + 'is authored as a page with a `print` block, which `os validate` checks: the parse refuses ' + + '`print` on a page that does not print its own authored blocks, and the printable block ' + + 'subset refuses any other block inside it. `data/DocumentSchemaValidation` (the NoSQL ' + + 'driver\'s schema-validation block, a different declaration) is unaffected. The four defs ' + + 'are absent from `json-schema.manifest/data.json`, the api-surface / declaration-map / ' + + 'export-origins shards and the generated reference docs. ⚠️ Runtime behaviour is ' + + 'deliberately UNCHANGED and must be verified as such: nothing ever parsed or read these ' + + 'shapes, so removing them removes no behaviour.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index cd840b3e8cb..190e421f305 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5482,6 +5482,21 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'the column as an orphan the boot drift report names, and `os migrate apply --allow-destructive` ' + 'drops it. The D3 records are the seven `sys-*-organization-column-retired` semantic entries.', }, + { + id: 'document-schemas-retired', + order: 88, + text: + 'It also retires the document family WHOLE (ADR-0049 enforce-or-remove; the ruling of record on ' + + 'PDF and print documents, letter B′, 2026-10-08: "A document is a page with a print ' + + 'declaration; no new template type"): the four defs of `data/document.zod.ts` — ' + + '`data/DocumentTemplate` (a docx template with placeholders), `data/Document`, ' + + '`data/ESignatureConfig` and the orphaned `data/DocumentVersion` — exported from ' + + '`@objectstack/spec/data`, mounted by no stack key, registered as no metadata type and read by ' + + 'nothing in this repository, objectui or hotcrm, leave via RETIRED_DEFS_BY_MAJOR with one D3 ' + + 'semantic entry, so that "template" means one thing: a printable document is a page that ' + + 'declares `print`. The `ESignatureConfig` deadline-key tombstones leave with their def\'s source ' + + 'and their RETIRED_KEYS_BY_MAJOR[18] entries stay as history.', + }, { id: 'duration-keys-unit-in-key', order: 24, @@ -10794,6 +10809,64 @@ const step18: MigrationStep = { + '`intervalSeconds` off the request response and waits that many seconds between polls, ' + 'exactly as `interval` did — the value and its unit are unchanged, only the key name moves.', }, + { + id: 'document-schemas-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'the document family, retired whole: the four defs data/DocumentTemplate, data/Document, ' + + 'data/ESignatureConfig and data/DocumentVersion, and every name data/document.zod.ts ' + + 'exported from @objectstack/spec/data (DocumentTemplateSchema, DocumentSchema, ' + + 'ESignatureConfigSchema, DocumentVersionSchema, their z.input aliases and their Parsed ' + + 'aliases)', + replacement: + 'a printable document is a PAGE that declares `print` — no separate template type. Author ' + + 'the document (an invoice, a delivery order, a letter, a report) as an ordinary `page` ' + + 'with `kind: \'full\'`, its blocks in `regions` drawn from the printable block subset ' + + '(`record:details`, `record:highlights`, `record:line_items`, `element:text`, ' + + '`element:image` and the rest of PRINTABLE_PAGE_COMPONENT_TYPES), and a `print` block for ' + + 'the paper, margins, running header and footer, page numbers and page-break hints. A ' + + 'docx-with-placeholders template, a stored document with versions, and an e-signature ' + + 'workflow have no replacement, because nothing on the platform ever merged, stored or ' + + 'sent any of them; a document record the organisation keeps is ordinary object data, and ' + + 'its files are `sys_file` attachments', + reason: + 'ADR-0049 enforce-or-remove, by the ruling of record on the PDF / print document card ' + + '(letter B′, 2026-10-08): "A document is a page with a print declaration; no new template ' + + 'type", and "The zero-reader DocumentTemplateSchema, DocumentSchema and ' + + 'ESignatureConfigSchema retire in v18 under ADR-0049 with ADR-0087 entries, so that ' + + '\'template\' means one thing." Four defs sat on the exported surface and in the generated ' + + 'reference docs — a docx template with typed placeholders, a document with versioning, ' + + 'access control and an e-signature block, and the signer workflow — and were read by ' + + 'NOTHING: they were exported from `@objectstack/spec/data`, mounted by no `stack.zod.ts` ' + + 'key, registered as no metadata type and absent from every liveness ledger, and the ' + + 'reader census over every package, app and example outside `packages/spec` (generated ' + + 'reference docs, release notes and changelogs aside), over objectui at its pin and its ' + + 'main, and over hotcrm returned zero hits for every exported name, against lit controls. ' + + 'Keeping them would have given an author two meanings of "template" — the dead docx one ' + + 'and the print page — and an AI that imports DocumentTemplateSchema a schema no runtime ' + + 'reads. DocumentVersionSchema had one carrier, DocumentSchema.versioning, and leaves with ' + + 'it. The ESignatureConfig deadline-key tombstones (RETIRED_KEYS_BY_MAJOR[18], D3 ' + + '`esignature-config-deadline-keys-retired`) leave with their def\'s source; their registry ' + + 'entries stay as history. Why D3 semantic and not a D2 conversion: the chain walks a ' + + 'normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its ' + + 'collections; none of these schemas is either, so a conversion would be a transform with no ' + + 'seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent), and ' + + 'with no carrier key there is no shape on which a tombstone could sit. `cloud` and real ' + + 'customer code are UNMEASURED.', + acceptanceCriteria: + 'No code imports DocumentTemplateSchema, DocumentSchema, ESignatureConfigSchema or ' + + 'DocumentVersionSchema — or any of their type aliases — from @objectstack/spec or ' + + '@objectstack/spec/data: every such import is TS2305 after upgrade. A printable document ' + + 'is authored as a page with a `print` block, which `os validate` checks: the parse refuses ' + + '`print` on a page that does not print its own authored blocks, and the printable block ' + + 'subset refuses any other block inside it. `data/DocumentSchemaValidation` (the NoSQL ' + + 'driver\'s schema-validation block, a different declaration) is unaffected. The four defs ' + + 'are absent from `json-schema.manifest/data.json`, the api-surface / declaration-map / ' + + 'export-origins shards and the generated reference docs. ⚠️ Runtime behaviour is ' + + 'deliberately UNCHANGED and must be verified as such: nothing ever parsed or read these ' + + 'shapes, so removing them removes no behaviour.', + }, { id: 'driver-options-timeout-to-timeout-ms', surface: '`DriverOptions.timeout` (data/driver.zod.ts) — the per-call options argument of every `IDataDriver` method', @@ -27941,6 +28014,87 @@ export const RETIRED_DEFS_BY_MAJOR: Readonly> // the cloud repo's own declarations, not an open-source protocol). Prescription: the // `cloud-subpath-retired` semantic entry of this major. 'cloud/VersionRelease', + // #22158 — `data/Document` — a stored document with versioning, a template, + // an e-signature block and access control, which no document store ever kept — + // leaves whole with the document family under ADR-0049 enforce-or-remove, by + // the ruling of record on #8346 (letter B′, maintainer 「8346 B′」 2026-10-08): + // "The zero-reader `DocumentTemplateSchema`, `DocumentSchema` and + // `ESignatureConfigSchema` retire in v18 under ADR-0049 with ADR-0087 entries, + // so that 'template' means one thing" — a printable document is a page that + // declares `print`. It was exported from `@objectstack/spec/data` + // (`data/document.zod.ts`), mounted by no `stack.zod.ts` key, registered as no + // metadata type, absent from every liveness ledger, and read by NOTHING: on + // objectstack `fec87e7e0` every hit for the family's exported names outside + // `packages/spec` was generated reference docs, release notes or a changelog; + // objectui (the `.objectui-sha` pin a58626c88 and main cef0eee) and hotcrm + // (1e88edc) returned zero, against lit controls on the same pattern. No carrier + // key, so no `retiredKey()` tombstone and no D2 conversion (none of these + // schemas is a stack collection member — the + // `kernel/MetadataPluginConfig:additionalTypes` reasoning): RETIRED_DEFS_BY_MAJOR + // plus the D3 semantic entry `document-schemas-retired` ARE the declaration. + 'data/Document', + // #22158 — `data/DocumentTemplate` — a docx template URL with typed + // placeholders (`fileUrl` / `fileType` / `placeholders[]`), which no template + // engine ever merged — + // leaves whole with the document family under ADR-0049 enforce-or-remove, by + // the ruling of record on #8346 (letter B′, maintainer 「8346 B′」 2026-10-08): + // "The zero-reader `DocumentTemplateSchema`, `DocumentSchema` and + // `ESignatureConfigSchema` retire in v18 under ADR-0049 with ADR-0087 entries, + // so that 'template' means one thing" — a printable document is a page that + // declares `print`. It was exported from `@objectstack/spec/data` + // (`data/document.zod.ts`), mounted by no `stack.zod.ts` key, registered as no + // metadata type, absent from every liveness ledger, and read by NOTHING: on + // objectstack `fec87e7e0` every hit for the family's exported names outside + // `packages/spec` was generated reference docs, release notes or a changelog; + // objectui (the `.objectui-sha` pin a58626c88 and main cef0eee) and hotcrm + // (1e88edc) returned zero, against lit controls on the same pattern. No carrier + // key, so no `retiredKey()` tombstone and no D2 conversion (none of these + // schemas is a stack collection member — the + // `kernel/MetadataPluginConfig:additionalTypes` reasoning): RETIRED_DEFS_BY_MAJOR + // plus the D3 semantic entry `document-schemas-retired` ARE the declaration. + 'data/DocumentTemplate', + // #22158 — `data/DocumentVersion` — one immutable version of a stored + // document, whose only carrier was `DocumentSchema.versioning.versions` (the + // orphan-value-schema rule takes it with its carrier) — + // leaves whole with the document family under ADR-0049 enforce-or-remove, by + // the ruling of record on #8346 (letter B′, maintainer 「8346 B′」 2026-10-08): + // "The zero-reader `DocumentTemplateSchema`, `DocumentSchema` and + // `ESignatureConfigSchema` retire in v18 under ADR-0049 with ADR-0087 entries, + // so that 'template' means one thing" — a printable document is a page that + // declares `print`. It was exported from `@objectstack/spec/data` + // (`data/document.zod.ts`), mounted by no `stack.zod.ts` key, registered as no + // metadata type, absent from every liveness ledger, and read by NOTHING: on + // objectstack `fec87e7e0` every hit for the family's exported names outside + // `packages/spec` was generated reference docs, release notes or a changelog; + // objectui (the `.objectui-sha` pin a58626c88 and main cef0eee) and hotcrm + // (1e88edc) returned zero, against lit controls on the same pattern. No carrier + // key, so no `retiredKey()` tombstone and no D2 conversion (none of these + // schemas is a stack collection member — the + // `kernel/MetadataPluginConfig:additionalTypes` reasoning): RETIRED_DEFS_BY_MAJOR + // plus the D3 semantic entry `document-schemas-retired` ARE the declaration. + 'data/DocumentVersion', + // #22158 — `data/ESignatureConfig` — an e-signature workflow (`provider` / + // `enabled` / `signers[]`), which no e-signature integration ever sent. Its + // `RETIRED_KEYS_BY_MAJOR[18]` deadline-key entries (`expirationDays` / + // `reminderDays`, the #14477 tombstones) stay as history — gate (b2) of + // build-schemas.ts accepts an entry naming a key the build no longer emits — + // leaves whole with the document family under ADR-0049 enforce-or-remove, by + // the ruling of record on #8346 (letter B′, maintainer 「8346 B′」 2026-10-08): + // "The zero-reader `DocumentTemplateSchema`, `DocumentSchema` and + // `ESignatureConfigSchema` retire in v18 under ADR-0049 with ADR-0087 entries, + // so that 'template' means one thing" — a printable document is a page that + // declares `print`. It was exported from `@objectstack/spec/data` + // (`data/document.zod.ts`), mounted by no `stack.zod.ts` key, registered as no + // metadata type, absent from every liveness ledger, and read by NOTHING: on + // objectstack `fec87e7e0` every hit for the family's exported names outside + // `packages/spec` was generated reference docs, release notes or a changelog; + // objectui (the `.objectui-sha` pin a58626c88 and main cef0eee) and hotcrm + // (1e88edc) returned zero, against lit controls on the same pattern. No carrier + // key, so no `retiredKey()` tombstone and no D2 conversion (none of these + // schemas is a stack collection member — the + // `kernel/MetadataPluginConfig:additionalTypes` reasoning): RETIRED_DEFS_BY_MAJOR + // plus the D3 semantic entry `document-schemas-retired` ARE the declaration. + 'data/ESignatureConfig', // Commit 2c86fe3ea — identity/identity.zod.ts `ApiKeySchema`, retired whole (ADR-0049 // enforce-or-remove; maintainer ruling 2026-08-15, disposition B: delete). // The schema documented better-auth's `apiKey` PLUGIN shape — a plugin this diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 757a6c1e324..a327509d614 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -33,6 +33,7 @@ "src/data/cube-member-inner-name-retirement.test.ts", "src/data/cube-refresh-key-retirement.test.ts", "src/data/currency-mode-family-closure.pin.test.ts", + "src/data/document-schemas-retirement.test.ts", "src/identity/position-delegatable-enforcer.pin.test.ts", "src/integration/connector-connection-timeout-retirement.test.ts", "src/integration/connector-resilience-keys-retirement.test.ts", From a7e786058f010cbc0094ee4a8524d5f5b8c4c713 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 04:09:01 +0000 Subject: [PATCH 04/15] chore(spec): regenerate the surface, docs and ledger artifacts for the print page and the document retirement json-schema.manifest/data.json -4 defs, authorable-surface/data.json -30 rows and authorable-defaults/data.json -2 (the deliberate hand-deletions gate (a) asks for on a whole-def retirement); ui.json +1 def and +9 rows for PagePrint and Page:print; api-surface, declaration-map and export-origins follow; the data/document reference page removed and the page reference regenerated; the strictness ledger drops its two document.zod.ts rows. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- content/docs/references/data/document.mdx | 150 ------------------ content/docs/references/data/index.mdx | 3 +- content/docs/references/data/meta.json | 1 - content/docs/references/index.mdx | 15 +- content/docs/references/ui/page.mdx | 49 +++++- .../data.md | 12 +- .../ui.md | 10 +- .../2026-07-unknown-key-strictness-ledger.md | 2 - packages/spec/api-surface/data.json | 12 -- packages/spec/api-surface/ui.json | 5 + packages/spec/authorable-defaults/data.json | 2 - packages/spec/authorable-surface/data.json | 30 ---- packages/spec/declaration-map/data.json | 8 - packages/spec/declaration-map/ui.json | 2 + packages/spec/export-origins/data.json | 12 -- packages/spec/export-origins/ui.json | 5 + packages/spec/json-schema.manifest/data.json | 4 - .../data/document-schemas-retirement.test.ts | 18 +-- 18 files changed, 85 insertions(+), 255 deletions(-) delete mode 100644 content/docs/references/data/document.mdx diff --git a/content/docs/references/data/document.mdx b/content/docs/references/data/document.mdx deleted file mode 100644 index 39e05501ad6..00000000000 --- a/content/docs/references/data/document.mdx +++ /dev/null @@ -1,150 +0,0 @@ ---- -title: Document schema — Data Protocol reference -navTitle: Document -description: "Document schemas of the ObjectStack Data Protocol: Document, DocumentTemplate and 2 more — each property with its type, default and a TypeScript example." ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - - -**Source:** `packages/spec/src/data/document.zod.ts` - - -## TypeScript Usage - -```typescript -import { DocumentSchema, DocumentTemplateSchema, DocumentVersionSchema, ESignatureConfigSchema } from '@objectstack/spec/data'; -import type { Document, DocumentTemplate, DocumentVersion, ESignatureConfig } from '@objectstack/spec/data'; - -// Validate data -const result = DocumentSchema.parse(data); -``` - ---- - -## Document - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Document ID | -| **name** | `string` | ✅ | Document name | -| **description** | `string` | optional | Document description | -| **fileType** | `string` | ✅ | File MIME type | -| **fileSize** | `number` | ✅ | File size in bytes | -| **category** | `string` | optional | Document category | -| **tags** | `string[]` | optional | Document tags | -| **versioning** | `{ enabled: boolean; versions: object[]; majorVersion: number; minorVersion: number }` | optional | Version control | -| **template** | `{ id: string; name: string; description?: string; fileUrl: string; … }` | optional | Document template | -| **eSignature** | `{ provider: Enum<'docusign' \| 'adobe-sign' \| 'hellosign' \| 'custom'>; enabled?: boolean; signers: object[] }` | optional | E-signature config | -| **access** | `{ isPublic?: boolean; sharedWith?: string[]; expiresAt?: integer }` | optional | Access control | -| **metadata** | `Record` | optional | Custom metadata | - -### Nested Shape: `Document.versioning` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Versioning enabled | -| **versions** | `{ versionNumber: number; createdAt: integer; createdBy: string; size: number; … }[]` | ✅ | Version history | -| **majorVersion** | `number` | ✅ | Major version | -| **minorVersion** | `number` | ✅ | Minor version | - -### Nested Shape: `Document.template` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Template ID | -| **name** | `string` | ✅ | Template name | -| **description** | `string` | optional | Template description | -| **fileUrl** | `string` | ✅ | Template file URL | -| **fileType** | `string` | ✅ | File MIME type | -| **placeholders** | `{ key: string; label: string; type: Enum<'text' \| 'number' \| 'date' \| 'image'>; required?: boolean }[]` | ✅ | Template placeholders | - -### Nested Shape: `Document.eSignature` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **provider** | `Enum<'docusign' \| 'adobe-sign' \| 'hellosign' \| 'custom'>` | ✅ | E-signature provider | -| **enabled** | `boolean` | optional (default: `false`) | E-signature enabled | -| **signers** | `{ email: string; name: string; role: string; order: number }[]` | ✅ | Document signers | -| **expirationDays** | `never` | optional | [REMOVED] `ESignatureConfig.expirationDays` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no e-signature engine exists on the platform, so no signature request was ever sent, expired or lapsed, and its default of 30 days was materialized into every parsed configuration without ever being consulted. Delete the key. There is no replacement, because no e-signature provider integration exists to keep an expiry window. | -| **reminderDays** | `never` | optional | [REMOVED] `ESignatureConfig.reminderDays` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no e-signature engine exists on the platform, so no reminder email was ever sent, and its default of 7 days was materialized into every parsed configuration without ever being consulted. Delete the key. There is no replacement, because no e-signature provider integration exists to send reminders. | - -### Nested Shape: `Document.access` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **isPublic** | `boolean` | optional (default: `false`) | Public access | -| **sharedWith** | `string[]` | optional | Shared with | -| **expiresAt** | `integer` | optional | Access expiration | - - ---- - -## DocumentTemplate - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Template ID | -| **name** | `string` | ✅ | Template name | -| **description** | `string` | optional | Template description | -| **fileUrl** | `string` | ✅ | Template file URL | -| **fileType** | `string` | ✅ | File MIME type | -| **placeholders** | `{ key: string; label: string; type: Enum<'text' \| 'number' \| 'date' \| 'image'>; required?: boolean }[]` | ✅ | Template placeholders | - -### Nested Shape: `DocumentTemplate.placeholders[number]` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **key** | `string` | ✅ | Placeholder key | -| **label** | `string` | ✅ | Placeholder label | -| **type** | `Enum<'text' \| 'number' \| 'date' \| 'image'>` | ✅ | Placeholder type | -| **required** | `boolean` | optional (default: `false`) | Is required | - - ---- - -## DocumentVersion - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **versionNumber** | `number` | ✅ | Version number | -| **createdAt** | `integer` | ✅ | Creation timestamp | -| **createdBy** | `string` | ✅ | Creator user ID | -| **size** | `number` | ✅ | File size in bytes | -| **checksum** | `string` | ✅ | File checksum | -| **downloadUrl** | `string` | ✅ | Download URL | -| **isLatest** | `boolean` | optional (default: `false`) | Is latest version | - - ---- - -## ESignatureConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **provider** | `Enum<'docusign' \| 'adobe-sign' \| 'hellosign' \| 'custom'>` | ✅ | E-signature provider | -| **enabled** | `boolean` | optional (default: `false`) | E-signature enabled | -| **signers** | `{ email: string; name: string; role: string; order: number }[]` | ✅ | Document signers | -| **expirationDays** | `never` | optional | [REMOVED] `ESignatureConfig.expirationDays` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no e-signature engine exists on the platform, so no signature request was ever sent, expired or lapsed, and its default of 30 days was materialized into every parsed configuration without ever being consulted. Delete the key. There is no replacement, because no e-signature provider integration exists to keep an expiry window. | -| **reminderDays** | `never` | optional | [REMOVED] `ESignatureConfig.reminderDays` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no e-signature engine exists on the platform, so no reminder email was ever sent, and its default of 7 days was materialized into every parsed configuration without ever being consulted. Delete the key. There is no replacement, because no e-signature provider integration exists to send reminders. | - -### Nested Shape: `ESignatureConfig.signers[number]` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **email** | `string` | ✅ | Signer email | -| **name** | `string` | ✅ | Signer name | -| **role** | `string` | ✅ | Signer role | -| **order** | `number` | ✅ | Signing order | - - ---- - diff --git a/content/docs/references/data/index.mdx b/content/docs/references/data/index.mdx index 2388cce913f..5f2370560ec 100644 --- a/content/docs/references/data/index.mdx +++ b/content/docs/references/data/index.mdx @@ -1,7 +1,7 @@ --- title: Data Protocol — complete schema reference navTitle: Data Protocol -description: "The ObjectStack Data Protocol in 30 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example." +description: "The ObjectStack Data Protocol in 29 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -14,7 +14,6 @@ This section contains all protocol schemas for the data layer of ObjectStack. - diff --git a/content/docs/references/data/meta.json b/content/docs/references/data/meta.json index 2d33c427dcc..065cbf6dbe4 100644 --- a/content/docs/references/data/meta.json +++ b/content/docs/references/data/meta.json @@ -21,7 +21,6 @@ "driver-sql", "external-catalog", "---Documents & Seed---", - "document", "feed", "seed", "seed-loader", diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 70735ca465b..5874c470ac3 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1516 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1513 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -23,7 +23,7 @@ counts are sums of the rows they head. Regenerate with | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | | [API Protocol](/docs/references/api) | 32 | 430 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 13 | 70 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | -| [Data Protocol](/docs/references/data) | 30 | 178 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | +| [Data Protocol](/docs/references/data) | 29 | 174 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | | [Integration Protocol](/docs/references/integration) | 1 | 12 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | | [Kernel Protocol](/docs/references/kernel) | 30 | 157 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | @@ -33,8 +33,8 @@ counts are sums of the rows they head. Regenerate with | [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 34 | 275 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | -| [UI Protocol](/docs/references/ui) | 16 | 165 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **196** | **1516** | 14 protocol modules | +| [UI Protocol](/docs/references/ui) | 16 | 166 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | +| **Total** | **195** | **1513** | 14 protocol modules | --- @@ -130,7 +130,7 @@ Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execu ## Data Protocol -**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **30 pages, 178 schemas** +**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **29 pages, 174 schemas** Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. @@ -141,7 +141,6 @@ Objects, fields, queries, filters, datasources and drivers — the ObjectQL laye | [`data-engine.zod.ts`](/docs/references/data/data-engine) | `BaseEngineOptions`, `DataEngineAggregateOptions`, `DataEngineAggregateRequest`, `DataEngineCountOptions`, `DataEngineCountRequest`, `DataEngineDeleteOptions`, `DataEngineDeleteRequest`, `DataEngineExecuteRequest`, `DataEngineFilter`, `DataEngineFindOneRequest`, `DataEngineFindRequest`, `DataEngineInsertOptions`, `DataEngineInsertRequest`, `DataEngineQueryOptions`, `DataEngineRequest`, `DataEngineSort`, `DataEngineUpdateOptions`, `DataEngineUpdateRequest`, `DataEngineVectorFindRequest`, `DroppedFieldsEvent`, `EngineAggregateOptions`, `EngineCountOptions`, `EngineDeleteOptions`, `EngineQueryOptions`, `EngineUpdateOptions`, `QueryTransportParams`, `QueryWithTransport` | | [`datasource.zod.ts`](/docs/references/data/datasource) | `Datasource`, `DriverDefinition`, `DriverType`, `ExternalDatasourceSettings`, `SchemaMode` | | [`date-macros.zod.ts`](/docs/references/data/date-macros) | `DateMacroPlaceholder`, `DateMacroToken` | -| [`document.zod.ts`](/docs/references/data/document) | `Document`, `DocumentTemplate`, `DocumentVersion`, `ESignatureConfig` | | [`driver.zod.ts`](/docs/references/data/driver) | `DriverCapabilities`, `DriverConfig`, `DriverOptions`, `PoolConfig` | | [`driver/common.zod.ts`](/docs/references/data/driver-common) | `DriverSslToggle`, `SqlAutoMigrate` | | [`driver/memory.zod.ts`](/docs/references/data/driver-memory) | `AutoPersistenceConfig`, `FilePersistenceConfig`, `LocalStoragePersistenceConfig`, `PersistenceType` | @@ -363,7 +362,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a ## UI Protocol -**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 165 schemas** +**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 166 schemas** Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. @@ -380,7 +379,7 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay | [`expression-bindable-text-keys.zod.ts`](/docs/references/ui/expression-bindable-text-keys) | `ExpressionBindableTextKey` | | [`i18n.zod.ts`](/docs/references/ui/i18n) | `AriaProps`, `I18nLabel`, `InlineLocaleMap` | | [`notification.zod.ts`](/docs/references/ui/notification) | `NotificationPosition`, `NotificationSeverity`, `NotificationType` | -| [`page.zod.ts`](/docs/references/ui/page) | `ElementDataSource`, `InterfacePageConfig`, `Page`, `PageComponent`, `PageComponentType`, `PageRegion`, `PageType`, `PageVariable` | +| [`page.zod.ts`](/docs/references/ui/page) | `ElementDataSource`, `InterfacePageConfig`, `Page`, `PageComponent`, `PageComponentType`, `PagePrint`, `PageRegion`, `PageType`, `PageVariable` | | [`report.zod.ts`](/docs/references/ui/report) | `JoinedReportBlock`, `Report`, `ReportChart`, `ReportSort`, `ReportType` | | [`responsive.zod.ts`](/docs/references/ui/responsive) | `ResponsiveStyles`, `StyleMap` | | [`sharing.zod.ts`](/docs/references/ui/sharing) | `SharingConfig` | diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 58a1291e20f..66508a2b4cf 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -1,7 +1,7 @@ --- title: Page schema — UI Protocol property reference navTitle: Page -description: "Page schemas of the ObjectStack UI Protocol: ElementDataSource, InterfacePageConfig and 6 more — each property with its type, default and a TypeScript example." +description: "Page schemas of the ObjectStack UI Protocol: ElementDataSource, InterfacePageConfig and 7 more — each property with its type, default and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -13,8 +13,8 @@ description: "Page schemas of the ObjectStack UI Protocol: ElementDataSource, In ## TypeScript Usage ```typescript -import { ElementDataSourceSchema, InterfacePageConfigSchema, PageSchema, PageComponentSchema, PageComponentType, PageRegionSchema, PageTypeSchema, PageVariableSchema } from '@objectstack/spec/ui'; -import type { ElementDataSource, InterfacePageConfig, Page, PageComponent, PageComponentType, PageRegion, PageType, PageVariable } from '@objectstack/spec/ui'; +import { ElementDataSourceSchema, InterfacePageConfigSchema, PageSchema, PageComponentSchema, PageComponentType, PagePrintSchema, PageRegionSchema, PageTypeSchema, PageVariableSchema } from '@objectstack/spec/ui'; +import type { ElementDataSource, InterfacePageConfig, Page, PageComponent, PageComponentType, PagePrint, PageRegion, PageType, PageVariable } from '@objectstack/spec/ui'; // Validate data const result = ElementDataSourceSchema.parse(data); @@ -181,6 +181,7 @@ View filter rule | **assignedProfiles** | `never` | optional | [REMOVED] `page.assignedProfiles` was removed in @objectstack/spec 17.5.0 (ADR-0090 D2, ADR-0049 enforce-or-remove) — it was named for the Profile concept ADR-0090 D2 deleted, and it gated nothing: no renderer, route or metadata read door ever read the key, so a page that "assigned profiles" stayed open to every caller who could reach it. Delete the key. Page audience is the permission set's: gate the DATA the page shows with the object's permission sets, and bind those sets to people through positions (`sys_position_permission_set`) — those are the checks the runtime actually runs. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **interfaceConfig** | `{ source?: string; columns?: string[] \| object[]; sort?: object[]; filterBy?: object[]; … }` | optional | Interface-level page configuration (for Airtable-style interface pages) | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | +| **print** | `{ paperSize?: Enum<'A4' \| 'A5' \| 'Letter' \| 'Legal'>; orientation?: Enum<'portrait' \| 'landscape'>; margins?: object; repeatHeader?: boolean; … }` | optional | Print declaration — makes this page a print page, a document authored in the page's own blocks: paper size and orientation, margins in millimetres, the running header and footer (the page's own `header` / `footer` regions), page numbers and page-break hints, each mapped to print CSS. A print page is a `kind: 'full'` page with its blocks in `regions`, and every block in it must come from the printable block subset: containers that draw every child (`page:section`, `page:card`, `page:footer`), field blocks (`record:details`, `record:highlights`), the child-record table `record:line_items`, `element:text`, `element:image`, `element:divider`, `element:definition-list`, `element:repeater`, `element:number` and `object-metric`; `os validate` / `os build` / `os lint` and the metadata save door refuse any other block inside it. Consumer: the console's browser print rendering, and later the server-side PDF renderer; until it ships, the declaration is validated but nothing applies it. A list view's print button is `allowPrinting`, not this. | | **kind** | `Enum<'full' \| 'slotted' \| 'html' \| 'react' \| 'jsx'>` | optional (default: `"full"`) | Page override mode. full \| slotted = structured authoring; html = author-written constrained JSX compiled (parsed, never executed) to the tree (ADR-0080; the legacy value 'jsx' is a deprecated alias), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors; react = real-React source executed at render by the runtime (ADR-0081), styled by inline `style` with the same token colors; it runs author JS, so it is gated by a host capability that defaults ON and is disabled server-side via the OS_PAGE_REACT=off env toggle. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080). | | **slots** | `{ header?: object \| object[]; actions?: object \| object[]; alerts?: object \| object[]; highlights?: object \| object[]; … }` | optional | Slot override map for slotted pages | | **source** | `string` | optional | Page source text. For kind==='html' (alias 'jsx') it is constrained JSX compiled to the tree by @objectstack/sdui-parser at save time (parse, never execute), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors. For kind==='react' it is real React/JSX executed at render by @object-ui/react-runtime (trusted tier), styled by inline `style` with the same token colors. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080). Authoritative over `regions` in both. | @@ -237,6 +238,19 @@ View filter rule | **ariaDescribedBy** | `string` | optional | ID of element providing additional description (WAI-ARIA aria-describedby) | | **role** | `string` | optional | WAI-ARIA role attribute (e.g., "dialog", "navigation", "alert") | +### Nested Shape: `Page.print` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **paperSize** | `Enum<'A4' \| 'A5' \| 'Letter' \| 'Legal'>` | optional | Paper size — the size keyword of the print CSS `@page { size }` rule (A4 when omitted). Consumer: the console's browser print rendering, and later the server-side PDF renderer; until it ships, nothing applies it. | +| **orientation** | `Enum<'portrait' \| 'landscape'>` | optional | Sheet orientation — the orientation keyword of the `@page { size }` rule (portrait when omitted). Consumer: the console's browser print rendering, and later the server-side PDF renderer; until it ships, nothing applies it. | +| **margins** | `{ top?: number; right?: number; bottom?: number; left?: number }` | optional | Sheet margins in MILLIMETRES, one number per side — the `@page { margin }` rule; a side left out takes the renderer's default. Consumer: the console's browser print rendering, and later the server-side PDF renderer; until it ships, nothing applies it. | +| **repeatHeader** | `boolean` | optional | Repeat the page's `header` region (its `regions` entry named `header`) at the top of every printed sheet — the running header. The parse refuses it on a page with no `header` region. Off when omitted: the region prints once, at the top of the first sheet. Consumer: the console's browser print rendering; until it ships, nothing applies it. | +| **repeatFooter** | `boolean` | optional | Repeat the page's `footer` region (its `regions` entry named `footer`) at the bottom of every printed sheet — the running footer. The parse refuses it on a page with no `footer` region. Off when omitted: the region prints once, after the body. Consumer: the console's browser print rendering; until it ships, nothing applies it. | +| **pageNumbers** | `boolean` | optional | Print the sheet number and the sheet count in the bottom margin of every sheet — the `@page` margin box with `counter(page)` and `counter(pages)`. Off when omitted. Consumer: the console's browser print rendering; until it ships, nothing applies it. | +| **repeatTableHeaders** | `boolean` | optional | Page-break hint: repeat a table's column headings at the top of every sheet the table runs onto (`thead { display: table-header-group }`). On when omitted. Consumer: the console's browser print rendering; until it ships, nothing applies it. | +| **avoidBreakInside** | `boolean` | optional | Page-break hint: keep each block whole on one sheet when it fits, moving it to the next sheet instead of splitting it (`break-inside: avoid` on every block). Off when omitted. Consumer: the console's browser print rendering; until it ships, nothing applies it. | + --- @@ -327,6 +341,35 @@ View filter rule * `element:text_input` +--- + +## PagePrint + +Print declaration: paper size, orientation, margins, the running header and footer, page numbering and page-break hints. Its presence makes the page a print page, whose blocks must come from the printable block subset. + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **paperSize** | `Enum<'A4' \| 'A5' \| 'Letter' \| 'Legal'>` | optional | Paper size — the size keyword of the print CSS `@page { size }` rule (A4 when omitted). Consumer: the console's browser print rendering, and later the server-side PDF renderer; until it ships, nothing applies it. | +| **orientation** | `Enum<'portrait' \| 'landscape'>` | optional | Sheet orientation — the orientation keyword of the `@page { size }` rule (portrait when omitted). Consumer: the console's browser print rendering, and later the server-side PDF renderer; until it ships, nothing applies it. | +| **margins** | `{ top?: number; right?: number; bottom?: number; left?: number }` | optional | Sheet margins in MILLIMETRES, one number per side — the `@page { margin }` rule; a side left out takes the renderer's default. Consumer: the console's browser print rendering, and later the server-side PDF renderer; until it ships, nothing applies it. | +| **repeatHeader** | `boolean` | optional | Repeat the page's `header` region (its `regions` entry named `header`) at the top of every printed sheet — the running header. The parse refuses it on a page with no `header` region. Off when omitted: the region prints once, at the top of the first sheet. Consumer: the console's browser print rendering; until it ships, nothing applies it. | +| **repeatFooter** | `boolean` | optional | Repeat the page's `footer` region (its `regions` entry named `footer`) at the bottom of every printed sheet — the running footer. The parse refuses it on a page with no `footer` region. Off when omitted: the region prints once, after the body. Consumer: the console's browser print rendering; until it ships, nothing applies it. | +| **pageNumbers** | `boolean` | optional | Print the sheet number and the sheet count in the bottom margin of every sheet — the `@page` margin box with `counter(page)` and `counter(pages)`. Off when omitted. Consumer: the console's browser print rendering; until it ships, nothing applies it. | +| **repeatTableHeaders** | `boolean` | optional | Page-break hint: repeat a table's column headings at the top of every sheet the table runs onto (`thead { display: table-header-group }`). On when omitted. Consumer: the console's browser print rendering; until it ships, nothing applies it. | +| **avoidBreakInside** | `boolean` | optional | Page-break hint: keep each block whole on one sheet when it fits, moving it to the next sheet instead of splitting it (`break-inside: avoid` on every block). Off when omitted. Consumer: the console's browser print rendering; until it ships, nothing applies it. | + +### Nested Shape: `PagePrint.margins` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **top** | `number` | optional | Top margin, in millimetres | +| **right** | `number` | optional | Right margin, in millimetres | +| **bottom** | `number` | optional | Bottom margin, in millimetres | +| **left** | `number` | optional | Left margin, in millimetres | + + --- ## PageRegion diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md index 8159ddb8358..31858e3e7c2 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `data/` | 163 | 79 | 2 | 0 | 82 | +| `data/` | 155 | 79 | 2 | 0 | 74 | ## `data/` — sites @@ -34,7 +34,6 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `analytics.zod.ts` | 6 | | `data-engine.zod.ts` | 15 | | `datasource.zod.ts` | 6 | -| `document.zod.ts` | 8 | | `driver-nosql.zod.ts` | 10 | | `driver-sql.zod.ts` | 2 | | `driver.zod.ts` | 9 | @@ -57,7 +56,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `seed-loader.zod.ts` | 12 | | `seed.zod.ts` | 1 | | `validation.zod.ts` | 6 | -| **total** | **163** | +| **total** | **155** | ## `data/` — open @@ -65,12 +64,11 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**82 strip of 163**, in 11 file(s). +**74 strip of 155**, in 10 file(s). | File | Strip | Sites | |---|---|---| | `data-engine.zod.ts` | 15 | 15 | -| `document.zod.ts` | 8 | 8 | | `driver-nosql.zod.ts` | 10 | 10 | | `driver-sql.zod.ts` | 2 | 2 | | `driver.zod.ts` | 9 | 9 | @@ -80,13 +78,13 @@ over it is here. | `hook.zod.ts` | 5 | 7 | | `query.zod.ts` | 4 | 5 | | `seed-loader.zod.ts` | 12 | 12 | -| **total** | **82** | **163** | +| **total** | **74** | **155** | | Bucket | Sites | |---|---| | authorable — the ruling's forced scope | 0 | | unresolved — needs a per-schema verdict | 0 | -| wire / open — out of forced scope | 80 | +| wire / open — out of forced scope | 72 | | no door — no carrier, ADR-0049 territory | 2 | | no gate — carrier live, no parse | 0 | | covered — no carrier, no parse, guarded at every consumer | 0 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md index 659ef8d5964..7863d772481 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 208 | 197 | 4 | 0 | 7 | +| `ui/` | 210 | 199 | 4 | 0 | 7 | ## `ui/` — sites @@ -40,13 +40,13 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `dashboard.zod.ts` | 11 | | `dataset.zod.ts` | 4 | | `i18n.zod.ts` | 1 | -| `page.zod.ts` | 7 | +| `page.zod.ts` | 9 | | `report.zod.ts` | 3 | | `responsive.zod.ts` | 1 | | `sharing.zod.ts` | 1 | | `view.zod.ts` | 60 | | `widget.zod.ts` | 1 | -| **total** | **208** | +| **total** | **210** | ## `ui/` — open @@ -54,7 +54,7 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**7 strip of 208**, in 4 file(s). +**7 strip of 210**, in 4 file(s). | File | Strip | Sites | |---|---|---| @@ -62,7 +62,7 @@ over it is here. | `app.zod.ts` | 1 | 19 | | `view.zod.ts` | 4 | 60 | | `widget.zod.ts` | 1 | 1 | -| **total** | **7** | **208** | +| **total** | **7** | **210** | | Bucket | Sites | |---|---| diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 574168a6bf1..017baf3831c 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -726,7 +726,6 @@ column does not move and the `strip` column falls by the count of what left. | `driver/turso.zod.ts` | authorable | The libSQL/Turso `config` contract, added by **#6345** — and the last driver on the platform whose `config` had no gate at all. It was not an oversight of #4410 but a consequence of turso not being a BUILTIN: its driver ships in the optional `@objectstack/driver-turso` package, so `resolveDriverId('turso')` returned `undefined` and `validateDriverConfig` answered `{ known: false }` — "nothing to check against" — while both boot hosts dispatched `turso` for real. A datasource carrying `{ token: … }` (the plausible spelling; the driver reads `authToken`) was therefore accepted in silence and then connected UNAUTHENTICATED, which is #4410's own failure mode surviving in the one driver #4410 could not see. Every site strict, same error factory as the rest of the campaign, including the nested `sync` block — a bare `z.object` there would have dropped `sync: { interval: 60 }` and synced on the default while the author believed otherwise, i.e. added a strip site to this map instead of closing one. The declared keys are drawn from what `TursoDriverConfig` actually READS, not from what libSQL supports, so closing this gap does not open an ADR-0049 one: `client` (a live `@libsql/client` instance — not authorable metadata), `pool` and `schemaMode`/`readOnly` (datasource-level, like every other driver) are deliberately absent | | `driver/mysql.zod.ts` / `driver/sqlite.zod.ts` | authorable | The rest of the `config` contract, added by #4410. `mysql.zod.ts` and `sqlite.zod.ts` (sqlite + sqlite-wasm) are shapes that **never existed** — both driver ids were offered by the connection form and buildable by the shared factory, with no config contract anywhere, so `driver: 'sqlite'` + a misspelled `filename` was an ephemeral `:memory:` database reported as configured. All three sites strict, same error factory as the rest of the campaign. (Their sibling `driver/common.zod.ts` holds shared enums and prescription strings and has no `z.object(` site, so the coverage gate skips it) | | `analytics.zod.ts` | authorable | **strict as of #4001 batch D — all 8 sites; the `mixed (p)` resolved to authorable on both halves.** **Two of the eight sites later left with their keys (ADR-0049 enforce-or-remove): the metric `filters[]` item (#10414) and the cube's `refreshKey` block (#20637, retired whole — nothing read `every` or `sql`, and no analytics result is cached). The batch-D verdicts for those two are superseded, not reopened; their pins in `analytics-strictness-batchd.test.ts` now assert the retirement prescriptions.** The cube family (Metric + its `filters[]` item, Dimension, CubeJoin, Cube + `refreshKey`) has two live authoring doors, measured: `defineCube()` `.parse()`s an author literal (the showcase example authors through it) and `defineStack({ analyticsCubes })` carries every cube through `StackSchema.parse` — the whole family resolves REACHABLE in the batch-D BFS (positive/negative controls green in the same run). Pre-close probes: a cube's `publik`, a metric's `title`, a join's `relationshipp` (falling back to the `many_to_one` default — a different join shape under a successful parse), a `refreshKey.sqll` all parsed clean and vanished. The query half was subtler and is the batch's live behaviour change: `AnalyticsQuerySchema`'s TOP level was already gated at its one production door (`api/analytics.zod.ts`'s `AnalyticsQueryRequestSchema` is `.extend(…).strict()` since #3878), but **top-level strictness does not recurse** — measured on `main`, `timeDimensions: [{ dimension, granuarity: 'day' }]` rode through the strict wrapper with the typo silently stripped, bucketing the whole range as one group under an ordinary 200. Closing the base makes the posture hold at every door instead of only at the wrapper that re-applied it. ADR-0010 envelope deliberately NOT declared: no protected item re-parses here (`CubeRegistry.register` takes typed objects without a parse; `analytics_cube` resolves no `getMetadataTypeSchema` entry so `saveMetaItem` never parses it; artifact ingest parses the compiled definition BEFORE `applyProtection` stamps). Producer sweep over objectui/cloud: zero cube/analytics-vocabulary producers (objectui's `data-objectstack` sends only declared keys — `cube`/`measures`/`dimensions`/`where`) | -| `document.zod.ts` | wire (p) | | | `hook.zod.ts` / `hook-body.zod.ts` | mixed | **strict as of #4001 data step** for the AUTHORING shapes: `HookSchema` (+ `retryPolicy`) and both body branches (`ExpressionBodySchema` / `ScriptBodySchema`). `HookContextSchema` and its `session` / `provenance` / `user` blocks are the RUNTIME shape the engine hands a handler — they stay tolerant, and must: strictness there would make an engine-internal enrichment (as `provenance` was in #3712) a breaking change for anyone parsing a context they were given. The file's old blanket `authorable (p)` was too wide — verification split it | | `mapping.zod.ts` | authorable (p) | | | `picklist.zod.ts` | mixed | `PicklistSchema` and `PicklistExtensionSchema` are authoring shapes, strict from birth (`strictObject`). `PicklistServedFieldSchema` is a SERVED shape — the slice a resolved picklist reference adds to a served field — and is a `looseObject` by design: every other key of that field is `FieldSchema`'s | @@ -1258,7 +1257,6 @@ triage row record which one was taken. | `filter.zod.ts` | open | **out of scope** — query dialect; user data flows through, validated semantically elsewhere | | `driver-nosql.zod.ts` | wire | **out of scope** | | `driver.zod.ts` | wire | **out of scope** — driver capability contract | -| `document.zod.ts` | wire (p) | `DocumentTemplate` / `ESignatureConfig` read authorable on their face — the `(p)` is unresolved, verify before scheduling either way | | `query.zod.ts` | open | ~~⚠️ classification conflict — see #4721~~ **RESOLVED (11:41Z ruling, closed by #4721).** The conflict was real and the answer was that per-FILE classification was the imprecise instrument: `SortNodeSchema` was carved out as `authorable` and closed (`strictObject` + `aliases: { direction: 'order' }`), the other 4 sites keep `open`. Those 4 are the dialect proper — `BaseQuerySchema`, `AggregationNodeSchema`, `FullTextSearchSchema`, `GroupByNodeSchema`'s object arm — and `BaseQuerySchema`'s own top-level strictness is #4001's to schedule, deliberately **not** taken by #4721 | | `external-catalog.zod.ts` | wire (p) | **out of scope** | | `hook.zod.ts` | wire | **out of scope** — `HookContextSchema` + `.session`/`.provenance`/`.user` are the runtime shape handed to a handler; verified in the data step | diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 1f7f04170c2..6a4654dd1f2 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -220,18 +220,9 @@ "DimensionType (const)", "DimensionType (type)", "DisplayNameObjectMeta (interface)", - "Document (type)", - "DocumentParsed (type)", - "DocumentSchema (const)", "DocumentSchemaValidation (type)", "DocumentSchemaValidationParsed (type)", "DocumentSchemaValidationSchema (const)", - "DocumentTemplate (type)", - "DocumentTemplateParsed (type)", - "DocumentTemplateSchema (const)", - "DocumentVersion (type)", - "DocumentVersionParsed (type)", - "DocumentVersionSchema (const)", "DottedFilterHeadClass (type)", "DottedFilterHeadMeta (interface)", "DriverCapabilities (type)", @@ -256,9 +247,6 @@ "DroppedFieldsEventSchema (const)", "EMPTY_OPERATOR_ARMS (const)", "ENGINE_UPDATE_UPSERT_REMOVED (const)", - "ESignatureConfig (type)", - "ESignatureConfigParsed (type)", - "ESignatureConfigSchema (const)", "EffectiveApiMethods (interface)", "EmptyOperatorArm (type)", "EmptyOperatorExpansion (interface)", diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index aea96d46494..0b28904473a 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -312,6 +312,8 @@ "ObjectTreePropsSchema (const)", "ObjectUserFiltersSchema (const)", "PAGE_TYPE_ROADMAP (const)", + "PRINTABLE_PAGE_COMPONENT_TYPES (const)", + "PRINT_REFUSED_PAGE_COMPONENT_TYPES (const)", "Page (type)", "PageAccordionProps (const)", "PageCardProps (const)", @@ -328,6 +330,8 @@ "PageNavItemParsed (type)", "PageNavItemSchema (const)", "PageParsed (type)", + "PagePrint (type)", + "PagePrintSchema (const)", "PageRegion (type)", "PageRegionParsed (type)", "PageRegionSchema (const)", @@ -488,6 +492,7 @@ "checkDashboardWidgetStageOrder (function)", "checkGlobalFilterDateDefaultValue (function)", "checkListViewCalendarVisualization (function)", + "checkPagePrintComposition (function)", "checkPageRequiresKind (function)", "checkPageSourceCompleteness (function)", "columnSummaryAlias (function)", diff --git a/packages/spec/authorable-defaults/data.json b/packages/spec/authorable-defaults/data.json index 9918bb5dceb..91328c785ed 100644 --- a/packages/spec/authorable-defaults/data.json +++ b/packages/spec/authorable-defaults/data.json @@ -23,8 +23,6 @@ "data/Datasource:origin = \"code\"", "data/Datasource:schemaMode = \"managed\"", "data/DocumentSchemaValidation:enabled = false", - "data/DocumentVersion:isLatest = false", - "data/ESignatureConfig:enabled = false", "data/EngineDeleteOptions:multi = false", "data/EngineUpdateOptions:multi = false", "data/EngineUpdateOptions:returning = false", diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index f34b64c158a..9de9b65d328 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -197,35 +197,10 @@ "data/Dimension:name [RETIRED]", "data/Dimension:sql", "data/Dimension:type", - "data/Document:access", - "data/Document:category", - "data/Document:description", - "data/Document:eSignature", - "data/Document:fileSize", - "data/Document:fileType", - "data/Document:id", - "data/Document:metadata", - "data/Document:name", - "data/Document:tags", - "data/Document:template", - "data/Document:versioning", "data/DocumentSchemaValidation:enabled", "data/DocumentSchemaValidation:jsonSchema", "data/DocumentSchemaValidation:validationAction", "data/DocumentSchemaValidation:validationLevel", - "data/DocumentTemplate:description", - "data/DocumentTemplate:fileType", - "data/DocumentTemplate:fileUrl", - "data/DocumentTemplate:id", - "data/DocumentTemplate:name", - "data/DocumentTemplate:placeholders", - "data/DocumentVersion:checksum", - "data/DocumentVersion:createdAt", - "data/DocumentVersion:createdBy", - "data/DocumentVersion:downloadUrl", - "data/DocumentVersion:isLatest", - "data/DocumentVersion:size", - "data/DocumentVersion:versionNumber", "data/DriverCapabilities:arrayFields [RETIRED]", "data/DriverCapabilities:autonumber", "data/DriverCapabilities:batchSchemaSync", @@ -284,11 +259,6 @@ "data/DroppedFieldsEvent:fields", "data/DroppedFieldsEvent:object", "data/DroppedFieldsEvent:reason", - "data/ESignatureConfig:enabled", - "data/ESignatureConfig:expirationDays [RETIRED]", - "data/ESignatureConfig:provider", - "data/ESignatureConfig:reminderDays [RETIRED]", - "data/ESignatureConfig:signers", "data/EngineAggregateOptions:aggregations", "data/EngineAggregateOptions:context", "data/EngineAggregateOptions:groupBy", diff --git a/packages/spec/declaration-map/data.json b/packages/spec/declaration-map/data.json index 987884d9b19..4f68ec57095 100644 --- a/packages/spec/declaration-map/data.json +++ b/packages/spec/declaration-map/data.json @@ -93,14 +93,8 @@ "Dimension": "data/Dimension", "DimensionSchema": "data/Dimension", "DimensionType": "data/DimensionType", - "Document": "data/Document", - "DocumentSchema": "data/Document", "DocumentSchemaValidation": "data/DocumentSchemaValidation", "DocumentSchemaValidationSchema": "data/DocumentSchemaValidation", - "DocumentTemplate": "data/DocumentTemplate", - "DocumentTemplateSchema": "data/DocumentTemplate", - "DocumentVersion": "data/DocumentVersion", - "DocumentVersionSchema": "data/DocumentVersion", "DriverCapabilities": "data/DriverCapabilities", "DriverCapabilitiesSchema": "data/DriverCapabilities", "DriverConfig": "data/DriverConfig", @@ -114,8 +108,6 @@ "DriverType": "data/DriverType", "DroppedFieldsEvent": "data/DroppedFieldsEvent", "DroppedFieldsEventSchema": "data/DroppedFieldsEvent", - "ESignatureConfig": "data/ESignatureConfig", - "ESignatureConfigSchema": "data/ESignatureConfig", "EngineAggregateOptions": "data/EngineAggregateOptions", "EngineAggregateOptionsSchema": "data/EngineAggregateOptions", "EngineCountOptions": "data/EngineCountOptions", diff --git a/packages/spec/declaration-map/ui.json b/packages/spec/declaration-map/ui.json index d22e57eedb4..5a5eb114fe7 100644 --- a/packages/spec/declaration-map/ui.json +++ b/packages/spec/declaration-map/ui.json @@ -213,6 +213,8 @@ "PageHeaderProps": "ui/PageHeaderProps", "PageNavItem": "ui/PageNavItem", "PageNavItemSchema": "ui/PageNavItem", + "PagePrint": "ui/PagePrint", + "PagePrintSchema": "ui/PagePrint", "PageRegion": "ui/PageRegion", "PageRegionSchema": "ui/PageRegion", "PageSchema": "ui/Page", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 68e506aa24a..cc52f70bf9f 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -216,18 +216,9 @@ "DimensionSchema": "src/data/analytics.zod.ts#DimensionSchema (const)", "DimensionType": "src/data/analytics.zod.ts#DimensionType (type)", "DisplayNameObjectMeta": "src/data/display-name.ts#DisplayNameObjectMeta (interface)", - "Document": "src/data/document.zod.ts#Document (type)", - "DocumentParsed": "src/data/document.zod.ts#DocumentParsed (type)", - "DocumentSchema": "src/data/document.zod.ts#DocumentSchema (const)", "DocumentSchemaValidation": "src/data/driver-nosql.zod.ts#DocumentSchemaValidation (type)", "DocumentSchemaValidationParsed": "src/data/driver-nosql.zod.ts#DocumentSchemaValidationParsed (type)", "DocumentSchemaValidationSchema": "src/data/driver-nosql.zod.ts#DocumentSchemaValidationSchema (const)", - "DocumentTemplate": "src/data/document.zod.ts#DocumentTemplate (type)", - "DocumentTemplateParsed": "src/data/document.zod.ts#DocumentTemplateParsed (type)", - "DocumentTemplateSchema": "src/data/document.zod.ts#DocumentTemplateSchema (const)", - "DocumentVersion": "src/data/document.zod.ts#DocumentVersion (type)", - "DocumentVersionParsed": "src/data/document.zod.ts#DocumentVersionParsed (type)", - "DocumentVersionSchema": "src/data/document.zod.ts#DocumentVersionSchema (const)", "DottedFilterHeadClass": "src/data/filter-dotted-head.ts#DottedFilterHeadClass (type)", "DottedFilterHeadMeta": "src/data/filter-dotted-head.ts#DottedFilterHeadMeta (interface)", "DriverCapabilities": "src/data/driver.zod.ts#DriverCapabilities (type)", @@ -251,9 +242,6 @@ "DroppedFieldsEventSchema": "src/data/data-engine.zod.ts#DroppedFieldsEventSchema (const)", "EMPTY_OPERATOR_ARMS": "src/data/filter-empty-operator.ts#EMPTY_OPERATOR_ARMS (const)", "ENGINE_UPDATE_UPSERT_REMOVED": "src/data/data-engine.zod.ts#ENGINE_UPDATE_UPSERT_REMOVED (const)", - "ESignatureConfig": "src/data/document.zod.ts#ESignatureConfig (type)", - "ESignatureConfigParsed": "src/data/document.zod.ts#ESignatureConfigParsed (type)", - "ESignatureConfigSchema": "src/data/document.zod.ts#ESignatureConfigSchema (const)", "EffectiveApiMethods": "src/data/api-derivation.ts#EffectiveApiMethods (interface)", "EmptyOperatorArm": "src/data/filter-empty-operator.ts#EmptyOperatorArm (type)", "EmptyOperatorExpansion": "src/data/filter-empty-operator.ts#EmptyOperatorExpansion (interface)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 11a38f51a7e..26e94e5a860 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -308,6 +308,8 @@ "ObjectTreePropsSchema": "src/ui/component.zod.ts#ObjectTreePropsSchema (const)", "ObjectUserFiltersSchema": "src/ui/view.zod.ts#ObjectUserFiltersSchema (const)", "PAGE_TYPE_ROADMAP": "src/ui/page.zod.ts#PAGE_TYPE_ROADMAP (const)", + "PRINTABLE_PAGE_COMPONENT_TYPES": "src/ui/page.zod.ts#PRINTABLE_PAGE_COMPONENT_TYPES (const)", + "PRINT_REFUSED_PAGE_COMPONENT_TYPES": "src/ui/page.zod.ts#PRINT_REFUSED_PAGE_COMPONENT_TYPES (const)", "Page": "src/ui/page.zod.ts#Page (type)", "PageAccordionProps": "src/ui/component.zod.ts#PageAccordionProps (const)", "PageCardProps": "src/ui/component.zod.ts#PageCardProps (const)", @@ -322,6 +324,8 @@ "PageNavItemParsed": "src/ui/app.zod.ts#PageNavItemParsed (type)", "PageNavItemSchema": "src/ui/app.zod.ts#PageNavItemSchema (const)", "PageParsed": "src/ui/page.zod.ts#PageParsed (type)", + "PagePrint": "src/ui/page.zod.ts#PagePrint (type)", + "PagePrintSchema": "src/ui/page.zod.ts#PagePrintSchema (const)", "PageRegion": "src/ui/page.zod.ts#PageRegion (type)", "PageRegionParsed": "src/ui/page.zod.ts#PageRegionParsed (type)", "PageRegionSchema": "src/ui/page.zod.ts#PageRegionSchema (const)", @@ -473,6 +477,7 @@ "checkDashboardWidgetStageOrder": "src/ui/dashboard.zod.ts#checkDashboardWidgetStageOrder (function)", "checkGlobalFilterDateDefaultValue": "src/ui/dashboard.zod.ts#checkGlobalFilterDateDefaultValue (function)", "checkListViewCalendarVisualization": "src/ui/view.zod.ts#checkListViewCalendarVisualization (function)", + "checkPagePrintComposition": "src/ui/page.zod.ts#checkPagePrintComposition (function)", "checkPageRequiresKind": "src/ui/page.zod.ts#checkPageRequiresKind (function)", "checkPageSourceCompleteness": "src/ui/page.zod.ts#checkPageSourceCompleteness (function)", "columnSummaryAlias": "src/ui/view-grouping-query.ts#columnSummaryAlias (function)", diff --git a/packages/spec/json-schema.manifest/data.json b/packages/spec/json-schema.manifest/data.json index 43355db09f1..4f6e693f181 100644 --- a/packages/spec/json-schema.manifest/data.json +++ b/packages/spec/json-schema.manifest/data.json @@ -53,10 +53,7 @@ "data/DateMacroToken", "data/Dimension", "data/DimensionType", - "data/Document", "data/DocumentSchemaValidation", - "data/DocumentTemplate", - "data/DocumentVersion", "data/DriverCapabilities", "data/DriverConfig", "data/DriverDefinition", @@ -64,7 +61,6 @@ "data/DriverSslToggle", "data/DriverType", "data/DroppedFieldsEvent", - "data/ESignatureConfig", "data/EngineAggregateOptions", "data/EngineCountOptions", "data/EngineDeleteOptions", diff --git a/packages/spec/src/data/document-schemas-retirement.test.ts b/packages/spec/src/data/document-schemas-retirement.test.ts index 7be46b514f4..2ca076e9e08 100644 --- a/packages/spec/src/data/document-schemas-retirement.test.ts +++ b/packages/spec/src/data/document-schemas-retirement.test.ts @@ -28,9 +28,9 @@ * 3. The `ESignatureConfig` deadline-key entries in `RETIRED_KEYS_BY_MAJOR[18]` * stay as history (gate (b2) accepts an entry naming a key the build no * longer emits). - * 4. The family's exports are gone from `@objectstack/spec/data` and from the - * root `Data` namespace, while the unrelated `DocumentSchemaValidation` - * survives (the lit control). + * 4. The family's exports are gone from `@objectstack/spec/data` (and the + * root entry carries none of them), while the unrelated + * `DocumentSchemaValidation` survives (the lit control). * 5. Tree-scoped absence: nothing inside the declared radius still imports a * retired export from a spec specifier or reaches one through `Data.`. */ @@ -42,7 +42,7 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import * as data from './index'; -import { Data } from '../index'; +import * as root from '../index'; import { MIGRATIONS_BY_MAJOR, RETIRED_DEFS_BY_MAJOR, @@ -78,14 +78,13 @@ describe('the document family is retired whole, and registered', () => { }); describe('the family\'s exports are gone from every entry that carried them', () => { - it.each(RETIRED_VALUE_EXPORTS)('`%s` is exported neither from `@objectstack/spec/data` nor through `Data`', (name) => { + it.each(RETIRED_VALUE_EXPORTS)('`%s` is exported neither from `@objectstack/spec/data` nor from the root entry', (name) => { expect(Object.keys(data)).not.toContain(name); - expect(Object.keys(Data)).not.toContain(name); + expect(Object.keys(root)).not.toContain(name); }); it('the unrelated `DocumentSchemaValidationSchema` (the NoSQL driver block) survives — the lit control', () => { expect(Object.keys(data)).toContain('DocumentSchemaValidationSchema'); - expect(Object.keys(Data)).toContain('DocumentSchemaValidationSchema'); }); }); @@ -102,7 +101,8 @@ describe('the family\'s exports are gone from every entry that carried them', () // - an `import` / `export … from` naming a retired export from an // `@objectstack/spec` specifier — the type aliases included, since inside a // spec import even the generic `Document` names the retired type; -// - a `Data.` namespace access. +// - a `Data.` access through the documented +// `import * as Data from '@objectstack/spec/data'` namespace. // Inline code is prose and is stripped before judging. The bound, stated: // `docs/**`, `.claude/**`, `.github/**` and the repo-root files are outside what // this walk sees. @@ -126,7 +126,7 @@ describe('tree-scoped absence: nothing inside the declared radius still imports const USES = [ // An import or re-export naming a retired export from a spec specifier. new RegExp(`\\b(import|export)\\s+(type\\s+)?\\{[^}]*\\b${RETIRED_NAMES}\\b[^}]*\\}\\s*from\\s*['"]@objectstack/spec`, 'm'), - // A namespace access through the root `Data` export. + // An access through the documented `import * as Data` namespace. new RegExp('\\bData\\.(DocumentTemplateSchema|DocumentSchema|ESignatureConfigSchema|DocumentVersionSchema)\\b', 'm'), ]; From 206aa8c29ebce58b242d4ee816c1c3e9288153d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 04:09:39 +0000 Subject: [PATCH 05/15] chore(changeset): the print page, the printable block subset and the document family retirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clause-② yes (narrowing): the print declaration widens PageSchema and the document family's exports leave @objectstack/spec/data. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- .changeset/22158-print-page-spec.md | 64 +++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 .changeset/22158-print-page-spec.md diff --git a/.changeset/22158-print-page-spec.md b/.changeset/22158-print-page-spec.md new file mode 100644 index 00000000000..fe7c79ba6b5 --- /dev/null +++ b/.changeset/22158-print-page-spec.md @@ -0,0 +1,64 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +--- + +feat(spec)!: a `page` gains an optional `print` declaration and a linted printable block subset; the list-export `'pdf'` prescription points at the print page; the zero-reader document schemas retire whole (#22158) + +Clause-②: yes (narrowing) + + + +**BREAKING** — the document family's exports leave `@objectstack/spec/data` (an export removal on a published entry), shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not yet in on `main`). The `print` declaration itself is additive. + +Card ① of the ruling on PDF and print documents (letter B′): **a document is a page with a print declaration; there is no separate template type.** + +### The print page (additive) + +`PageSchema` gains an optional, closed `print` block. Its presence makes the page a print page — a document (an invoice, a delivery order, a letter, a report) authored in the page's own blocks: + +```ts +definePage({ + name: 'invoice_print', + label: 'Invoice', + type: 'record', + object: 'invoice', + regions: [ + { name: 'header', components: [{ type: 'element:image', properties: { src: '/logo.png', alt: 'ACME' } }] }, + { name: 'main', components: [ + { type: 'record:details', properties: { fields: ['customer', 'invoice_date'] } }, + { type: 'record:line_items', properties: { childObject: 'invoice_line', relationshipField: 'invoice', columns: [{ name: 'description' }, { name: 'amount' }] } }, + ] }, + { name: 'footer', components: [{ type: 'element:text', properties: { content: 'Payment due within 30 days.' } }] }, + ], + print: { paperSize: 'A4', orientation: 'portrait', margins: { top: 15, bottom: 15 }, repeatHeader: true, repeatFooter: true, pageNumbers: true }, +}); +``` + +- **Keys** (`PagePrintSchema`): `paperSize` (`A4` | `A5` | `Letter` | `Legal`), `orientation` (`portrait` | `landscape`), `margins` (`{ top, right, bottom, left }` in millimetres), `repeatHeader` / `repeatFooter` (repeat the page's own `header` / `footer` region on every sheet), `pageNumbers`, and the page-break hints `repeatTableHeaders` and `avoidBreakInside`. Each maps to print CSS (`@page`, `thead { display: table-header-group }`, `break-inside`). +- **Not yet rendered.** Nothing applies these keys until the console's print rendering lands; the liveness ledger carries `print` as `planned` with an author warning, so `os validate` tells an author who writes it that the layout is validated but not yet applied. +- **Refused at the parse** (`checkPagePrintComposition`, exported for `.shape` mirrors): `print` on a `slotted` page, on an `html` / `jsx` / `react` page, on a `list` page, on a `full` page with no `regions`, and `repeatHeader` / `repeatFooter` on a page with no region of that name. +- **The printable block subset** (`PRINTABLE_PAGE_COMPONENT_TYPES`, with the reason for every other vocabulary type in `PRINT_REFUSED_PAGE_COMPONENT_TYPES`): `page:section`, `page:card`, `page:footer`, `record:details`, `record:highlights`, `record:line_items`, `element:text`, `element:image`, `element:divider`, `element:definition-list`, `element:repeater`, `element:number` and `object-metric`. Inside a print page any other block — one that pages or windows its rows (`object-grid`, `record:related_list`), one that lays itself out to the screen (`page:sidebar`, `object-kanban`, `object-calendar`), or one with nothing printable (controls, inputs, shell chrome, `page:tabs`) — is refused by `@objectstack/lint`'s new gating rule `print-page-block-unprintable` (`validatePrintPageBlocks`), on `os validate`, `os build`, `os lint` and the page save door. + +### The list-export prescription + +`'pdf'` stays refused in `view.exportOptions` formats. The refusal no longer says "PDF export itself was declined as NOT PLANNED" — no longer true — and instead names the view's `allowPrinting` for printing a list and a page that declares `print` for a document. + +### FROM → TO (the retirement) + +`DocumentTemplateSchema`, `DocumentSchema`, `ESignatureConfigSchema` and the orphaned `DocumentVersionSchema` (`data/document.zod.ts`) are removed from `@objectstack/spec/data`, with their type aliases (`DocumentTemplate`, `DocumentTemplateParsed`, `Document`, `DocumentParsed`, `ESignatureConfig`, `ESignatureConfigParsed`, `DocumentVersion`, `DocumentVersionParsed`). No code in this repository, objectui or hotcrm read them. + +| before | what to write instead | +| --- | --- | +| `DocumentTemplateSchema` (a docx template with placeholders) | a `page` that declares `print`, its body drawn from the printable block subset | +| `DocumentSchema` / `DocumentVersionSchema` | nothing — no document store ever kept them; a document record is ordinary object data, its files `sys_file` attachments | +| `ESignatureConfigSchema` | nothing — no e-signature integration exists | + +**The one-line fix: delete the import; author a printable document as a page with a `print` block.** Every such import is TS2305 after upgrade. `data/DocumentSchemaValidation` (the NoSQL driver's block) is unaffected. `os migrate meta --from 17` lists the delegated step (D3 `document-schemas-retired`); there is no mechanical edit, since no stack collection ever carried these shapes. + +### The retirement kit + +- `RETIRED_DEFS_BY_MAJOR[18]`: `data/DocumentTemplate`, `data/Document`, `data/ESignatureConfig`, `data/DocumentVersion`; D3 semantic entry `document-schemas-retired`, with a step-18 rationale fragment. The `ESignatureConfig` deadline-key entries in `RETIRED_KEYS_BY_MAJOR[18]` stay as history. +- No tombstone and no D2 conversion: none of the schemas is a stack collection member or a metadata type, so a conversion would have no seam that runs. +- Generated: `json-schema.manifest/data.json` −4 defs, `authorable-surface/data.json` −30 rows, `authorable-defaults/data.json` −2, the api-surface / declaration-map / export-origins shards, the `data/document` reference page removed. +- Pin: `src/data/document-schemas-retirement.test.ts`, a tree-scoped absence walk over the declared radius. From 7812db59a05ee91e2ff99a6245cff4e3b6e3eaa9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 04:12:59 +0000 Subject: [PATCH 06/15] chore(spec): regenerate the references index on the merged tree The merge script's designed collection point: content/docs/references/index.mdx regenerated from the merged sources, so main's API row (+1 schema) and this branch's data and ui rows compose. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- content/docs/references/index.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 5874c470ac3..0015e76fd65 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1513 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1514 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 32 | 430 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 32 | 431 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 13 | 70 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 29 | 174 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 34 | 275 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 166 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **195** | **1513** | 14 protocol modules | +| **Total** | **195** | **1514** | 14 protocol modules | --- @@ -63,7 +63,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 430 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 431 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -91,7 +91,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`package-api-assembled.zod.ts`](/docs/references/api/package-api-assembled) | `AssembledInstalledPackage`, `GetInstalledPackageResponse`, `InstalledPackageAtEitherStage`, `ListInstalledPackagesResponse` | | [`package-lifecycle.zod.ts`](/docs/references/api/package-lifecycle) | `DiscardPackageDraftsResponse`, `DuplicatePackageResponse`, `ListPackageCommitsResponse`, `PackageExportManifest`, `PackagePublishResult`, `ReassignOrphanedMetadataResponse`, `RevertPackageCommitResponse`, `RollbackToPackageCommitResponse` | | [`plugin-rest-api.zod.ts`](/docs/references/api/plugin-rest-api) | `ErrorHandlingConfig`, `OpenApiGenerationConfig`, `RequestValidationConfig`, `ResponseEnvelopeConfig`, `RestApiEndpoint`, `RestApiPluginConfig`, `RestApiRouteCategory`, `RestApiRouteRegistration`, `ValidationMode` | -| [`protocol.zod.ts`](/docs/references/api/protocol) | `AiAgentCapabilities`, `AiAgentChatRequest`, `AiAgentSummary`, `AiAgentsResponse`, `AiChatRequest`, `AiChatResponse`, `AiCompleteRequest`, `AiConversation`, `AiMessage`, `AiModelsResponse`, `AiPendingAction`, `AiPendingActionStatus`, `AiStreamChunk`, `ApproveAiPendingActionResponse`, `AuditMetaItemRequest`, `AuditMetaItemResponse`, `AutomationActionsResponse`, `AutomationTriggerRequest`, `AutomationTriggerResponse`, `BatchDataRequest`, `BatchDataResponse`, `CheckPermissionRequest`, `CheckPermissionResponse`, `CloneDataResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DiffMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `FindReferencesToMetaResponse`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaDiagnosticsResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredRequest`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetPublishedMetaItemResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HistoryMetaItemRequest`, `HistoryMetaItemResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListDraftsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `Notification`, `NotificationPreferences`, `PublishMetaItemRequest`, `PublishMetaItemResponse`, `PublishPackageDraftsResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `RollbackMetaItemResponse`, `RuntimeAuthoringIssue`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SearchAllHit`, `SearchAllPageHit`, `SearchAllResponse`, `SetPresenceRequest`, `SetPresenceResponse`, `UninstallPackageRequest`, `UninstallPackageResponse`, `UnregisterDeviceRequest`, `UnregisterDeviceResponse`, `UpdateAiConversationRequest`, `UpdateDataRequest`, `UpdateDataResponse`, `UpdateManyDataRequest`, `UpdateManyDataResponse`, `UpdateNotificationPreferencesRequest`, `UpdateNotificationPreferencesResponse`, `ValidateDataIssue`, `ValidateDataRequest`, `ValidateDataResponse` | +| [`protocol.zod.ts`](/docs/references/api/protocol) | `AiAgentCapabilities`, `AiAgentChatRequest`, `AiAgentSummary`, `AiAgentsResponse`, `AiChatRequest`, `AiChatResponse`, `AiCompleteRequest`, `AiConversation`, `AiMessage`, `AiModelsResponse`, `AiPendingAction`, `AiPendingActionStatus`, `AiStreamChunk`, `ApproveAiPendingActionResponse`, `AuditMetaItemRequest`, `AuditMetaItemResponse`, `AutomationActionsResponse`, `AutomationTriggerRequest`, `AutomationTriggerResponse`, `BatchDataRequest`, `BatchDataResponse`, `CheckPermissionRequest`, `CheckPermissionResponse`, `CloneDataResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DiffMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `FindReferencesToMetaResponse`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaDiagnosticsResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredRequest`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetPublishedMetaItemResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HistoryMetaItemRequest`, `HistoryMetaItemResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListDraftsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `MetadataConflictError`, `Notification`, `NotificationPreferences`, `PublishMetaItemRequest`, `PublishMetaItemResponse`, `PublishPackageDraftsResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `RollbackMetaItemResponse`, `RuntimeAuthoringIssue`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SearchAllHit`, `SearchAllPageHit`, `SearchAllResponse`, `SetPresenceRequest`, `SetPresenceResponse`, `UninstallPackageRequest`, `UninstallPackageResponse`, `UnregisterDeviceRequest`, `UnregisterDeviceResponse`, `UpdateAiConversationRequest`, `UpdateDataRequest`, `UpdateDataResponse`, `UpdateManyDataRequest`, `UpdateManyDataResponse`, `UpdateNotificationPreferencesRequest`, `UpdateNotificationPreferencesResponse`, `ValidateDataIssue`, `ValidateDataRequest`, `ValidateDataResponse` | | [`query-adapter.zod.ts`](/docs/references/api/query-adapter) | `ODataQueryAdapter`, `OperatorMapping`, `QueryAdapterConfig`, `QueryAdapterTarget`, `RestQueryAdapter` | | [`realtime.zod.ts`](/docs/references/api/realtime) | `RealtimeConfig`, `RealtimeEvent`, `RealtimeEventType`, `RealtimePresence`, `Subscription`, `SubscriptionEvent`, `TransportProtocol` | | [`realtime-shared.zod.ts`](/docs/references/api/realtime-shared) | `BasePresence`, `PresenceStatus`, `RealtimeRecordAction` | From 3e1462067d27a91faa25d96e08fa251e09d91a38 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 04:26:52 +0000 Subject: [PATCH 07/15] fix(spec): spell the cited objectui pin sha in backticks in the retirement records check:objectui-pin-citations finds a pin citation by its backticks; the retired-defs entries, the retirement pin and the print liveness rows now spell every cited sha that way. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- packages/spec/liveness/page.json | 24 +++++++++---------- .../data/document-schemas-retirement.test.ts | 4 ++-- .../entries/retired-defs/18.data__Document.ts | 4 ++-- .../retired-defs/18.data__DocumentTemplate.ts | 4 ++-- .../retired-defs/18.data__DocumentVersion.ts | 4 ++-- .../retired-defs/18.data__ESignatureConfig.ts | 4 ++-- packages/spec/src/migrations/registry.ts | 16 ++++++------- 7 files changed, 30 insertions(+), 30 deletions(-) diff --git a/packages/spec/liveness/page.json b/packages/spec/liveness/page.json index 6288330dbac..f5f830dad64 100644 --- a/packages/spec/liveness/page.json +++ b/packages/spec/liveness/page.json @@ -83,64 +83,64 @@ "paperSize": { "status": "planned", "verifiedAt": "2026-10-08", - "note": "PLANNED — maps to the size keyword of `@page { size }`. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin a58626c88 and at main cef0eee has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." + "note": "PLANNED — maps to the size keyword of `@page { size }`. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin `a58626c88` and at main `cef0eee` has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." }, "orientation": { "status": "planned", "verifiedAt": "2026-10-08", - "note": "PLANNED — maps to the orientation keyword of `@page { size }`. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin a58626c88 and at main cef0eee has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." + "note": "PLANNED — maps to the orientation keyword of `@page { size }`. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin `a58626c88` and at main `cef0eee` has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." }, "margins": { "status": "planned", "verifiedAt": "2026-10-08", - "note": "PLANNED — maps to `@page { margin }`, one side per key, in millimetres. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin a58626c88 and at main cef0eee has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit.", + "note": "PLANNED — maps to `@page { margin }`, one side per key, in millimetres. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin `a58626c88` and at main `cef0eee` has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit.", "children": { "top": { "status": "planned", "verifiedAt": "2026-10-08", - "note": "PLANNED — maps to the top value of `@page { margin }`, in millimetres. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin a58626c88 and at main cef0eee has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." + "note": "PLANNED — maps to the top value of `@page { margin }`, in millimetres. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin `a58626c88` and at main `cef0eee` has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." }, "right": { "status": "planned", "verifiedAt": "2026-10-08", - "note": "PLANNED — maps to the right value of `@page { margin }`, in millimetres. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin a58626c88 and at main cef0eee has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." + "note": "PLANNED — maps to the right value of `@page { margin }`, in millimetres. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin `a58626c88` and at main `cef0eee` has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." }, "bottom": { "status": "planned", "verifiedAt": "2026-10-08", - "note": "PLANNED — maps to the bottom value of `@page { margin }`, in millimetres. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin a58626c88 and at main cef0eee has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." + "note": "PLANNED — maps to the bottom value of `@page { margin }`, in millimetres. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin `a58626c88` and at main `cef0eee` has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." }, "left": { "status": "planned", "verifiedAt": "2026-10-08", - "note": "PLANNED — maps to the left value of `@page { margin }`, in millimetres. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin a58626c88 and at main cef0eee has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." + "note": "PLANNED — maps to the left value of `@page { margin }`, in millimetres. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin `a58626c88` and at main `cef0eee` has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." } } }, "repeatHeader": { "status": "planned", "verifiedAt": "2026-10-08", - "note": "PLANNED — maps to a running header — the page's `header` region repeated on every sheet. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin a58626c88 and at main cef0eee has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." + "note": "PLANNED — maps to a running header — the page's `header` region repeated on every sheet. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin `a58626c88` and at main `cef0eee` has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." }, "repeatFooter": { "status": "planned", "verifiedAt": "2026-10-08", - "note": "PLANNED — maps to a running footer — the page's `footer` region repeated on every sheet. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin a58626c88 and at main cef0eee has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." + "note": "PLANNED — maps to a running footer — the page's `footer` region repeated on every sheet. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin `a58626c88` and at main `cef0eee` has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." }, "pageNumbers": { "status": "planned", "verifiedAt": "2026-10-08", - "note": "PLANNED — maps to an `@page` margin box with `counter(page)` and `counter(pages)`. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin a58626c88 and at main cef0eee has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." + "note": "PLANNED — maps to an `@page` margin box with `counter(page)` and `counter(pages)`. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin `a58626c88` and at main `cef0eee` has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." }, "repeatTableHeaders": { "status": "planned", "verifiedAt": "2026-10-08", - "note": "PLANNED — maps to `thead { display: table-header-group }`. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin a58626c88 and at main cef0eee has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." + "note": "PLANNED — maps to `thead { display: table-header-group }`. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin `a58626c88` and at main `cef0eee` has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." }, "avoidBreakInside": { "status": "planned", "verifiedAt": "2026-10-08", - "note": "PLANNED — maps to `break-inside: avoid` on every block. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin a58626c88 and at main cef0eee has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." + "note": "PLANNED — maps to `break-inside: avoid` on every block. No reader in either repo: the key is new (#22158), so objectui at the `.objectui-sha` pin `a58626c88` and at main `cef0eee` has no `print` read at all. Carrier: card ② of the #8346 ruling (objectui — the print CSS mapping, the printable blocks and the record page's print action), to be filed once this declaration is on main; the server-side renderer is card ③. Flip to `live` citing the console reader at the `.objectui-sha` pin that carries it, and drop `authorWarn` / `authorHint` in that same edit." } } }, diff --git a/packages/spec/src/data/document-schemas-retirement.test.ts b/packages/spec/src/data/document-schemas-retirement.test.ts index 2ca076e9e08..292e8b733f5 100644 --- a/packages/spec/src/data/document-schemas-retirement.test.ts +++ b/packages/spec/src/data/document-schemas-retirement.test.ts @@ -15,8 +15,8 @@ * taken against): objectstack `fec87e7e0` — every hit for the family's names * outside `packages/spec` was generated reference docs, release notes or a * changelog (control: 148 files outside spec name `FieldSchema`); objectui at - * the `.objectui-sha` pin a58626c88 and at main cef0eee — 0 (control: 55 files - * name `PageSchema`); hotcrm 1e88edc — 0 (control: 77 files name + * the `.objectui-sha` pin `a58626c88` and at main `cef0eee` — 0 (control: 55 files + * name `PageSchema`); hotcrm `1e88edc` — 0 (control: 77 files name * `defineStack` / `ObjectSchema`). * * Bookkeeping shapes, pinned below: diff --git a/packages/spec/src/migrations/entries/retired-defs/18.data__Document.ts b/packages/spec/src/migrations/entries/retired-defs/18.data__Document.ts index 4bee44c2620..a7e33f827e3 100644 --- a/packages/spec/src/migrations/entries/retired-defs/18.data__Document.ts +++ b/packages/spec/src/migrations/entries/retired-defs/18.data__Document.ts @@ -12,8 +12,8 @@ // metadata type, absent from every liveness ledger, and read by NOTHING: on // objectstack `fec87e7e0` every hit for the family's exported names outside // `packages/spec` was generated reference docs, release notes or a changelog; -// objectui (the `.objectui-sha` pin a58626c88 and main cef0eee) and hotcrm -// (1e88edc) returned zero, against lit controls on the same pattern. No carrier +// objectui (the `.objectui-sha` pin `a58626c88` and main `cef0eee`) and hotcrm +// (`1e88edc`) returned zero, against lit controls on the same pattern. No carrier // key, so no `retiredKey()` tombstone and no D2 conversion (none of these // schemas is a stack collection member — the // `kernel/MetadataPluginConfig:additionalTypes` reasoning): RETIRED_DEFS_BY_MAJOR diff --git a/packages/spec/src/migrations/entries/retired-defs/18.data__DocumentTemplate.ts b/packages/spec/src/migrations/entries/retired-defs/18.data__DocumentTemplate.ts index c6f491515a3..dd07fcde6e0 100644 --- a/packages/spec/src/migrations/entries/retired-defs/18.data__DocumentTemplate.ts +++ b/packages/spec/src/migrations/entries/retired-defs/18.data__DocumentTemplate.ts @@ -13,8 +13,8 @@ // metadata type, absent from every liveness ledger, and read by NOTHING: on // objectstack `fec87e7e0` every hit for the family's exported names outside // `packages/spec` was generated reference docs, release notes or a changelog; -// objectui (the `.objectui-sha` pin a58626c88 and main cef0eee) and hotcrm -// (1e88edc) returned zero, against lit controls on the same pattern. No carrier +// objectui (the `.objectui-sha` pin `a58626c88` and main `cef0eee`) and hotcrm +// (`1e88edc`) returned zero, against lit controls on the same pattern. No carrier // key, so no `retiredKey()` tombstone and no D2 conversion (none of these // schemas is a stack collection member — the // `kernel/MetadataPluginConfig:additionalTypes` reasoning): RETIRED_DEFS_BY_MAJOR diff --git a/packages/spec/src/migrations/entries/retired-defs/18.data__DocumentVersion.ts b/packages/spec/src/migrations/entries/retired-defs/18.data__DocumentVersion.ts index 83cb6bd3115..7ac72cc29f2 100644 --- a/packages/spec/src/migrations/entries/retired-defs/18.data__DocumentVersion.ts +++ b/packages/spec/src/migrations/entries/retired-defs/18.data__DocumentVersion.ts @@ -13,8 +13,8 @@ // metadata type, absent from every liveness ledger, and read by NOTHING: on // objectstack `fec87e7e0` every hit for the family's exported names outside // `packages/spec` was generated reference docs, release notes or a changelog; -// objectui (the `.objectui-sha` pin a58626c88 and main cef0eee) and hotcrm -// (1e88edc) returned zero, against lit controls on the same pattern. No carrier +// objectui (the `.objectui-sha` pin `a58626c88` and main `cef0eee`) and hotcrm +// (`1e88edc`) returned zero, against lit controls on the same pattern. No carrier // key, so no `retiredKey()` tombstone and no D2 conversion (none of these // schemas is a stack collection member — the // `kernel/MetadataPluginConfig:additionalTypes` reasoning): RETIRED_DEFS_BY_MAJOR diff --git a/packages/spec/src/migrations/entries/retired-defs/18.data__ESignatureConfig.ts b/packages/spec/src/migrations/entries/retired-defs/18.data__ESignatureConfig.ts index cc4f6918df8..65d2a001f15 100644 --- a/packages/spec/src/migrations/entries/retired-defs/18.data__ESignatureConfig.ts +++ b/packages/spec/src/migrations/entries/retired-defs/18.data__ESignatureConfig.ts @@ -15,8 +15,8 @@ // metadata type, absent from every liveness ledger, and read by NOTHING: on // objectstack `fec87e7e0` every hit for the family's exported names outside // `packages/spec` was generated reference docs, release notes or a changelog; -// objectui (the `.objectui-sha` pin a58626c88 and main cef0eee) and hotcrm -// (1e88edc) returned zero, against lit controls on the same pattern. No carrier +// objectui (the `.objectui-sha` pin `a58626c88` and main `cef0eee`) and hotcrm +// (`1e88edc`) returned zero, against lit controls on the same pattern. No carrier // key, so no `retiredKey()` tombstone and no D2 conversion (none of these // schemas is a stack collection member — the // `kernel/MetadataPluginConfig:additionalTypes` reasoning): RETIRED_DEFS_BY_MAJOR diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 190e421f305..e1f3959d862 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -28026,8 +28026,8 @@ export const RETIRED_DEFS_BY_MAJOR: Readonly> // metadata type, absent from every liveness ledger, and read by NOTHING: on // objectstack `fec87e7e0` every hit for the family's exported names outside // `packages/spec` was generated reference docs, release notes or a changelog; - // objectui (the `.objectui-sha` pin a58626c88 and main cef0eee) and hotcrm - // (1e88edc) returned zero, against lit controls on the same pattern. No carrier + // objectui (the `.objectui-sha` pin `a58626c88` and main `cef0eee`) and hotcrm + // (`1e88edc`) returned zero, against lit controls on the same pattern. No carrier // key, so no `retiredKey()` tombstone and no D2 conversion (none of these // schemas is a stack collection member — the // `kernel/MetadataPluginConfig:additionalTypes` reasoning): RETIRED_DEFS_BY_MAJOR @@ -28046,8 +28046,8 @@ export const RETIRED_DEFS_BY_MAJOR: Readonly> // metadata type, absent from every liveness ledger, and read by NOTHING: on // objectstack `fec87e7e0` every hit for the family's exported names outside // `packages/spec` was generated reference docs, release notes or a changelog; - // objectui (the `.objectui-sha` pin a58626c88 and main cef0eee) and hotcrm - // (1e88edc) returned zero, against lit controls on the same pattern. No carrier + // objectui (the `.objectui-sha` pin `a58626c88` and main `cef0eee`) and hotcrm + // (`1e88edc`) returned zero, against lit controls on the same pattern. No carrier // key, so no `retiredKey()` tombstone and no D2 conversion (none of these // schemas is a stack collection member — the // `kernel/MetadataPluginConfig:additionalTypes` reasoning): RETIRED_DEFS_BY_MAJOR @@ -28066,8 +28066,8 @@ export const RETIRED_DEFS_BY_MAJOR: Readonly> // metadata type, absent from every liveness ledger, and read by NOTHING: on // objectstack `fec87e7e0` every hit for the family's exported names outside // `packages/spec` was generated reference docs, release notes or a changelog; - // objectui (the `.objectui-sha` pin a58626c88 and main cef0eee) and hotcrm - // (1e88edc) returned zero, against lit controls on the same pattern. No carrier + // objectui (the `.objectui-sha` pin `a58626c88` and main `cef0eee`) and hotcrm + // (`1e88edc`) returned zero, against lit controls on the same pattern. No carrier // key, so no `retiredKey()` tombstone and no D2 conversion (none of these // schemas is a stack collection member — the // `kernel/MetadataPluginConfig:additionalTypes` reasoning): RETIRED_DEFS_BY_MAJOR @@ -28088,8 +28088,8 @@ export const RETIRED_DEFS_BY_MAJOR: Readonly> // metadata type, absent from every liveness ledger, and read by NOTHING: on // objectstack `fec87e7e0` every hit for the family's exported names outside // `packages/spec` was generated reference docs, release notes or a changelog; - // objectui (the `.objectui-sha` pin a58626c88 and main cef0eee) and hotcrm - // (1e88edc) returned zero, against lit controls on the same pattern. No carrier + // objectui (the `.objectui-sha` pin `a58626c88` and main `cef0eee`) and hotcrm + // (`1e88edc`) returned zero, against lit controls on the same pattern. No carrier // key, so no `retiredKey()` tombstone and no D2 conversion (none of these // schemas is a stack collection member — the // `kernel/MetadataPluginConfig:additionalTypes` reasoning): RETIRED_DEFS_BY_MAJOR From e05df7f6b0395dd6acd1eefc83fb6a8ebd7e7e7f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 04:43:54 +0000 Subject: [PATCH 08/15] fix(spec): record the print key's form omission and keep the page-type prose count The page form does not offer print until the console's print rendering lands (the ruling puts its authoring UI in the page designer with it), so the reconciliation ledger records the omission. The list-page refusal names the page types in quoted form so the app-root prose pin keeps reading the page-type sentences it counts. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- .../system/metadata-form-zod-reconciliation.test.ts | 13 +++++++++++++ packages/spec/src/ui/page.zod.ts | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index cc2c8d5ba6f..a9c7828c955 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -331,6 +331,19 @@ const LEDGER: ReadonlyArray = [ key: 'requires', why: "platform-written, never authored — the schema's own words: `derived from the source at save — omit it`. The key exists only on an `html` / `jsx` page, where, on a server with the deployment's SDUI manifest, the save door stamps the compiled list and refuses a written list that disagrees (`page-requires-disagrees-with-source`); on every other kind the parse refuses it; and the Studio page editor drops the key on every save. A control would invite the list the describe tells every author to omit", }, + // #22158 — the print declaration (ruling B′ on #8346) lands spec-first: + // `planned` in the liveness ledger, read by no renderer until card ② (the + // console's print rendering), which is also where the ruling puts its + // authoring UI — "Studio's page designer is the designer, the print + // declaration is a few fields on it". Offering the keys in this form before + // anything applies them would be the UI half of declared-not-enforced. + { + kind: 'omit', + type: 'page', + path: ROOT_PATH, + key: 'print', + why: "planned, not yet rendered — the print declaration's keys (paper, margins, running header and footer, page numbers, page-break hints) are validated at parse and its printable block subset is enforced at the authoring doors, but no renderer applies them until the console's print rendering lands, and the ruling places the authoring UI in the Studio page designer with it. A form control offered today would let an author set a layout nothing prints", + }, { kind: 'omit', type: 'view', diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 39d4b888de3..d8c4a0ac0eb 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -1029,8 +1029,8 @@ export function checkPagePrintComposition( path: ['print'], message: '`print` is refused on a `type: \'list\'` page: a list page draws its records through ' + '`interfaceConfig` as a paged grid, not as authored blocks. To let users print a list as shown, ' - + 'set `interfaceConfig.allowPrinting: true`; to print a document, declare `print` on a `record`, ' - + '`home` or `app` page with its blocks in `regions`.', + + 'set `interfaceConfig.allowPrinting: true`; to print a document, declare `print` on a page of ' + + '`type: \'record\'`, `\'home\'` or `\'app\'` with its blocks in `regions`.', }); return; } From 4d7a5dfea0472cc3fcd7be7ce7a5f704c0caa554 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 04:44:19 +0000 Subject: [PATCH 09/15] docs: the quick reference drops the retired data/document row (16 of 30 -> 15 of 29) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- content/docs/getting-started/quick-reference.mdx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx index a117f8a072f..7e143ab1363 100644 --- a/content/docs/getting-started/quick-reference.mdx +++ b/content/docs/getting-started/quick-reference.mdx @@ -22,7 +22,7 @@ Categories that have no section here at all are named under [Categories Without a Section](#categories-without-a-section) — that curation is stated, not left implicit. -## Data Protocol (16 of 30 schemas) +## Data Protocol (15 of 29 schemas) Core business logic and data modeling schemas. @@ -41,7 +41,6 @@ Core business logic and data modeling schemas. | **[Driver](/docs/references/data/driver)** | `driver.zod.ts` | Driver, DriverCapabilities | Database driver interface | | **[SQL Driver](/docs/references/data/driver-sql)** | `driver-sql.zod.ts` | SQLDriverConfig, SQLDialect | SQL-specific driver | | **[NoSQL Driver](/docs/references/data/driver-nosql)** | `driver-nosql.zod.ts` | NoSQLDriverConfig | NoSQL-specific driver | -| **[Document](/docs/references/data/document)** | `document.zod.ts` | Document | Document-oriented data | | **[Postgres Driver](/docs/references/data/driver-postgres)** | `driver/postgres.zod.ts` | PostgresConfig | PostgreSQL configuration | | **[Mongo Driver](/docs/references/data/driver-mongo)** | `driver/mongo.zod.ts` | MongoConfig | MongoDB configuration | From d4b428426e24816b8850828de7bf03c8f42744cd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 04:51:51 +0000 Subject: [PATCH 10/15] docs: the CLI transcripts print 50 author-time rules (validatePrintPageBlocks joins the registry) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- content/docs/deployment/cli.mdx | 2 +- content/docs/deployment/validating-metadata.mdx | 2 +- content/docs/getting-started/build-with-claude-code.mdx | 2 +- content/docs/ui/react-pages.mdx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 5a12f54dc83..08d03fb61ce 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -675,7 +675,7 @@ os compile --json # JSON output for CI pipelines → Normalizing stack definition... → Lowering inline handlers... → Validating protocol compliance... - → Running author-time rules (49)... + → Running author-time rules (50)... → Checking that every required capability has a provider installable in this edition... → Collecting package docs (ADR-0046)... 0 collected → Writing artifact... diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index 1131c999b80..b4632a5b62c 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -721,7 +721,7 @@ A clean run walks the registry and reports timing: Config: /path/to/support-desk/objectstack.config.ts Load time: 21ms → Validating against ObjectStack Protocol... - → Running author-time rules (49)... + → Running author-time rules (50)... → Checking that every required capability has a provider installable in this edition... → Checking package docs (ADR-0046)... diff --git a/content/docs/getting-started/build-with-claude-code.mdx b/content/docs/getting-started/build-with-claude-code.mdx index 964cc27ebf7..b6ea29767a2 100644 --- a/content/docs/getting-started/build-with-claude-code.mdx +++ b/content/docs/getting-started/build-with-claude-code.mdx @@ -270,7 +270,7 @@ visible: 'status != "resolved"' ◆ Validate ──────────────────────────────────────── → Validating against ObjectStack Protocol... - → Running author-time rules (49)... + → Running author-time rules (50)... ✗ Author-time rules failed (1 issue) • stack · action 'resolve_ticket' visible: bare reference `status` — a diff --git a/content/docs/ui/react-pages.mdx b/content/docs/ui/react-pages.mdx index 62be319e8cd..f0e52ce3db7 100644 --- a/content/docs/ui/react-pages.mdx +++ b/content/docs/ui/react-pages.mdx @@ -389,7 +389,7 @@ objectstack validate ──────────────────────────────────────── → Loading configuration... → Validating against ObjectStack Protocol... - → Running author-time rules (49)... + → Running author-time rules (50)... → Checking that every required capability has a provider installable in this edition... → Checking package docs (ADR-0046)... From 8395484204369f42ddf3adaa46b29f1dda489c4c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 06:02:39 +0000 Subject: [PATCH 11/15] fix(spec,metadata-protocol): print is refused on a utility page; the /meta/types page control counts print Patch round 1 on the print page. The /meta/types control pinned page at 24 top-level properties; print is the 25th, a declared key, not a derivation change (the same note field and object carry). A utility page is a floating panel, not a document, so the parse refuses print there beside list, and every refusal names the admitted types (record, home, app) from one phrase. The type-alias pin's PageTypeSchema line gets its space back, and views.mdx stops saying PDF export was declined: pdf stays out of exportOptions, a list prints through allowPrinting, a document is a page that declares print. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- .changeset/22158-print-page-spec.md | 2 +- content/docs/ui/views.mdx | 2 +- ...l.meta-types-degenerate-derivation.test.ts | 5 +-- .../src/type-alias-convention.pin.test.ts | 2 +- .../object-refinement-check-exports.test.ts | 4 ++- packages/spec/src/ui/page-print.test.ts | 19 ++++++++++- packages/spec/src/ui/page.zod.ts | 34 ++++++++++++++++--- 7 files changed, 57 insertions(+), 11 deletions(-) diff --git a/.changeset/22158-print-page-spec.md b/.changeset/22158-print-page-spec.md index fe7c79ba6b5..b7039021a24 100644 --- a/.changeset/22158-print-page-spec.md +++ b/.changeset/22158-print-page-spec.md @@ -37,7 +37,7 @@ definePage({ - **Keys** (`PagePrintSchema`): `paperSize` (`A4` | `A5` | `Letter` | `Legal`), `orientation` (`portrait` | `landscape`), `margins` (`{ top, right, bottom, left }` in millimetres), `repeatHeader` / `repeatFooter` (repeat the page's own `header` / `footer` region on every sheet), `pageNumbers`, and the page-break hints `repeatTableHeaders` and `avoidBreakInside`. Each maps to print CSS (`@page`, `thead { display: table-header-group }`, `break-inside`). - **Not yet rendered.** Nothing applies these keys until the console's print rendering lands; the liveness ledger carries `print` as `planned` with an author warning, so `os validate` tells an author who writes it that the layout is validated but not yet applied. -- **Refused at the parse** (`checkPagePrintComposition`, exported for `.shape` mirrors): `print` on a `slotted` page, on an `html` / `jsx` / `react` page, on a `list` page, on a `full` page with no `regions`, and `repeatHeader` / `repeatFooter` on a page with no region of that name. +- **Refused at the parse** (`checkPagePrintComposition`, exported for `.shape` mirrors): `print` on a `slotted` page, on an `html` / `jsx` / `react` page, on a `list` or `utility` page (a print page is a `record`, `home` or `app` page), on a `full` page with no `regions`, and `repeatHeader` / `repeatFooter` on a page with no region of that name. - **The printable block subset** (`PRINTABLE_PAGE_COMPONENT_TYPES`, with the reason for every other vocabulary type in `PRINT_REFUSED_PAGE_COMPONENT_TYPES`): `page:section`, `page:card`, `page:footer`, `record:details`, `record:highlights`, `record:line_items`, `element:text`, `element:image`, `element:divider`, `element:definition-list`, `element:repeater`, `element:number` and `object-metric`. Inside a print page any other block — one that pages or windows its rows (`object-grid`, `record:related_list`), one that lays itself out to the screen (`page:sidebar`, `object-kanban`, `object-calendar`), or one with nothing printable (controls, inputs, shell chrome, `page:tabs`) — is refused by `@objectstack/lint`'s new gating rule `print-page-block-unprintable` (`validatePrintPageBlocks`), on `os validate`, `os build`, `os lint` and the page save door. ### The list-export prescription diff --git a/content/docs/ui/views.mdx b/content/docs/ui/views.mdx index 3907a235846..d6cc080004b 100644 --- a/content/docs/ui/views.mdx +++ b/content/docs/ui/views.mdx @@ -109,7 +109,7 @@ A List View controls how a collection of records is presented. It supports multi | `bulkActions` | `array` | optional | Bulk selection actions, by action name — see [Actions](/docs/ui/actions) | | `bulkActionDefs` | `array` | optional | Rich bulk action definitions — mass edits, and the aggregate single-call mode (below) | | `inlineEdit` | `boolean` | optional | Enable inline editing | -| `exportOptions` | `object` | optional | Export menu configuration: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. `formats` accepts `csv`, `xlsx`, `json` (default `['csv', 'json']`; PDF export was declined — #1301). A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse | +| `exportOptions` | `object` | optional | Export menu configuration: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. `formats` accepts `csv`, `xlsx`, `json` (default `['csv', 'json']`). `'pdf'` is not an export format: to let users print a list as shown, set the view's `allowPrinting`; a printable document (an invoice, a delivery order) is a page that declares `print` — see the [page reference](/docs/references/ui/page). A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse | The view's machine name is its **key** in the container (`listViews.urgent` on object `task` becomes `task.urgent`); the default `list` claims `task.default`. diff --git a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts index ceb2cbbe0cd..06490633555 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts @@ -304,11 +304,12 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no * disturbed by the fix. `field` moved 74 → 75 when it gained `picklist` * (the shared-option-list reference), a declared key, not a derivation change; * `object` moved 43 → 44 the same way when it gained `imageField` (the - * record's picture, beside `nameField`). + * record's picture, beside `nameField`). `page` moved 24 → 25 the same way + * when it gained `print` (#22158, the print-page declaration). */ const CARD_PROPERTY_COUNTS: Record = { agent: 26, app: 30, dashboard: 21, dataset: 16, field: 75, flow: 23, - hook: 22, object: 44, page: 24, position: 12, report: 21, skill: 17, tool: 14, + hook: 22, object: 44, page: 25, position: 12, report: 21, skill: 17, tool: 14, }; it.each(Object.entries(CARD_PROPERTY_COUNTS))( diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 7621d69dea0..fe9955a5a20 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -1550,7 +1550,7 @@ export type Iso_ui_page__PageComponentType = Assert, z.infer< typeof M163.PagePrintSchema > >>; -export type Iso_ui_page__PageTypeSchema =Assert, z.infer< typeof M163.PageTypeSchema > >>; +export type Iso_ui_page__PageTypeSchema = Assert, z.infer< typeof M163.PageTypeSchema > >>; // ui/report.zod.ts // `JoinedReportBlockSchema` left the family on #19920: its `z.ZodTypeAny` diff --git a/packages/spec/src/ui/object-refinement-check-exports.test.ts b/packages/spec/src/ui/object-refinement-check-exports.test.ts index e6ac39d2c6a..0ae3d47c8c6 100644 --- a/packages/spec/src/ui/object-refinement-check-exports.test.ts +++ b/packages/spec/src/ui/object-refinement-check-exports.test.ts @@ -239,7 +239,8 @@ const pageRequiresFixtures: Fixture[] = [ // A print page prints exactly the blocks it authors (#22158, ruling B′ on // #8346): `print` is refused on the kinds and types that draw blocks nobody -// authored, and a running header or footer needs the region it repeats. +// authored or are no document (`list`, `utility`), and a running header or +// footer needs the region it repeats. const PRINT_REGIONS = [ { name: 'header', components: [{ type: 'element:text' }] }, { name: 'main', components: [{ type: 'record:details' }] }, @@ -248,6 +249,7 @@ const pagePrintFixtures: Fixture[] = [ { label: '`print` on a `slotted` page', value: { ...PAGE_BASE, kind: 'slotted', regions: PRINT_REGIONS, print: {} }, refusesAt: ['print'] }, { label: '`print` on an `html` page with a `source`', value: { ...PAGE_BASE, kind: 'html', source: 'Card', regions: PRINT_REGIONS, print: {} }, refusesAt: ['print'] }, { label: '`print` on a `list` page', value: { ...PAGE_BASE, type: 'list', regions: PRINT_REGIONS, print: {} }, refusesAt: ['print'] }, + { label: '`print` on a `utility` page', value: { ...PAGE_BASE, type: 'utility', regions: PRINT_REGIONS, print: {} }, refusesAt: ['print'] }, { label: '`print` on a `full` page with no `regions`', value: { ...PAGE_BASE, print: {} }, refusesAt: ['print'] }, { label: '`print.repeatFooter` with no `footer` region', value: { ...PAGE_BASE, regions: PRINT_REGIONS, print: { repeatHeader: true, repeatFooter: true } }, refusesAt: ['print.repeatFooter'] }, { label: '`print` on a `full` page with `regions` and its `header` region', value: { ...PAGE_BASE, regions: PRINT_REGIONS, print: { repeatHeader: true } }, refusesAt: [] }, diff --git a/packages/spec/src/ui/page-print.test.ts b/packages/spec/src/ui/page-print.test.ts index 8c60804573c..d6b7b965cc6 100644 --- a/packages/spec/src/ui/page-print.test.ts +++ b/packages/spec/src/ui/page-print.test.ts @@ -129,6 +129,22 @@ describe('checkPagePrintComposition — a print page prints exactly the blocks i expect(issues[0].message).toMatch(/interfaceConfig\.allowPrinting/); }); + it('refuses `print` on a `utility` page — a floating panel, not a document — and names the admitted types', () => { + const issues = issuesOf({ ...BASE, type: 'utility', regions: REGIONS, print: {} }); + expect(issues.map((i) => [i.path, i.code])).toEqual([['print', 'custom']]); + expect(issues[0].message).toMatch(/type: 'utility'.*floating panel.*not a document/s); + expect(issues[0].message).toContain("`type: 'record'`, `'home'` or `'app'`"); + }); + + it('names the same admitted types in the `list` and `kind` refusals', () => { + for (const value of [ + { ...BASE, type: 'list', regions: REGIONS, print: {} }, + { ...BASE, kind: 'slotted', regions: REGIONS, print: {} }, + ]) { + expect(issuesOf(value)[0].message).toContain("`type: 'record'`, `'home'` or `'app'`"); + } + }); + it('refuses `print` on a full page with no regions — it would draw the synthesized default layout', () => { for (const value of [{ ...BASE, print: {} }, { ...BASE, regions: [], print: {} }]) { const issues = issuesOf(value); @@ -156,9 +172,10 @@ describe('checkPagePrintComposition — a print page prints exactly the blocks i } }); - it('leaves a page without `print` untouched — the slotted and list pages it refuses above still parse', () => { + it('leaves a page without `print` untouched — the slotted, list and utility pages it refuses above still parse', () => { expect(issuesOf({ ...BASE, kind: 'slotted' })).toEqual([]); expect(issuesOf({ ...BASE, type: 'list' })).toEqual([]); + expect(issuesOf({ ...BASE, type: 'utility' })).toEqual([]); expect(issuesOf({ ...BASE })).toEqual([]); }); }); diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index d8c4a0ac0eb..8b335c3fafb 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -978,6 +978,17 @@ export const PagePrintSchema = lazySchema(() => strictObject({ /** The page kinds a print page may take: the one that prints its authored `regions` and nothing else. */ const PRINT_PAGE_KINDS: readonly string[] = ['full']; +/** + * The page types a print page may take — a record page (the document bound to + * one record: an invoice, a delivery order) and a home or app page (a document + * bound to no single record: a letter, a monthly report). `list` draws a paged + * grid and `utility` is a floating panel; neither is a document. + */ +const PRINT_PAGE_TYPES: readonly string[] = ['record', 'home', 'app']; + +/** The admitted page types, spelled once for every refusal that names them. */ +const PRINT_PAGE_TYPES_PHRASE = "of `type: 'record'`, `'home'` or `'app'` with its blocks in `regions`"; + /** * The print-page composition check attached to {@link PageSchema} (ruling B′ * on #8346): a page that declares `print` must print exactly the blocks it @@ -992,6 +1003,8 @@ const PRINT_PAGE_KINDS: readonly string[] = ['full']; * judge; * - `print` on a `list` page — it draws its records through `interfaceConfig` * as a paged grid; the list's own print control is `allowPrinting`; + * - `print` on a `utility` page — a floating panel, not a document (the + * admitted types are {@link PRINT_PAGE_TYPES}); * - `print` on a `full` page with no `regions` — it draws the synthesized * default layout instead; * - `print.repeatHeader` / `print.repeatFooter` on a page with no region of @@ -1019,7 +1032,7 @@ export function checkPagePrintComposition( code: 'custom', path: ['print'], message: `\`print\` is refused on a \`kind: '${kind}'\` page: a print page prints exactly the blocks it ` - + `authors, and ${why}. Author the document as a \`kind: 'full'\` page with its blocks in \`regions\`.`, + + `authors, and ${why}. Author the document as a \`kind: 'full'\` page ${PRINT_PAGE_TYPES_PHRASE}.`, }); return; } @@ -1029,8 +1042,21 @@ export function checkPagePrintComposition( path: ['print'], message: '`print` is refused on a `type: \'list\'` page: a list page draws its records through ' + '`interfaceConfig` as a paged grid, not as authored blocks. To let users print a list as shown, ' - + 'set `interfaceConfig.allowPrinting: true`; to print a document, declare `print` on a page of ' - + '`type: \'record\'`, `\'home\'` or `\'app\'` with its blocks in `regions`.', + + `set \`interfaceConfig.allowPrinting: true\`; to print a document, declare \`print\` on a page ${PRINT_PAGE_TYPES_PHRASE}.`, + }); + return; + } + // `type` absent is the spec default, `record` — admitted. + const type = page.type ?? 'record'; + if (!PRINT_PAGE_TYPES.includes(type)) { + const why = type === 'utility' + ? 'a utility page is a floating panel beside the page a user is on (notes, a phone dialer), not a document' + : `a \`${type}\` page is not a document page`; + ctx.addIssue({ + code: 'custom', + path: ['print'], + message: `\`print\` is refused on a \`type: '${type}'\` page: ${why}. To print a document, declare ` + + `\`print\` on a page ${PRINT_PAGE_TYPES_PHRASE}.`, }); return; } @@ -1202,7 +1228,7 @@ export const PageSchema = lazySchema(() => strictObject({ * console's print rendering (card ②) reads it. */ print: PagePrintSchema.optional() - .describe("Print declaration — makes this page a print page, a document authored in the page's own blocks: paper size and orientation, margins in millimetres, the running header and footer (the page's own `header` / `footer` regions), page numbers and page-break hints, each mapped to print CSS. A print page is a `kind: 'full'` page with its blocks in `regions`, and every block in it must come from the printable block subset: containers that draw every child (`page:section`, `page:card`, `page:footer`), field blocks (`record:details`, `record:highlights`), the child-record table `record:line_items`, `element:text`, `element:image`, `element:divider`, `element:definition-list`, `element:repeater`, `element:number` and `object-metric`; `os validate` / `os build` / `os lint` and the metadata save door refuse any other block inside it. Consumer: the console's browser print rendering, and later the server-side PDF renderer; until it ships, the declaration is validated but nothing applies it. A list view's print button is `allowPrinting`, not this."), + .describe("Print declaration — makes this page a print page, a document authored in the page's own blocks: paper size and orientation, margins in millimetres, the running header and footer (the page's own `header` / `footer` regions), page numbers and page-break hints, each mapped to print CSS. A print page is a `kind: 'full'` page of `type: 'record'`, `'home'` or `'app'` with its blocks in `regions`, and every block in it must come from the printable block subset: containers that draw every child (`page:section`, `page:card`, `page:footer`), field blocks (`record:details`, `record:highlights`), the child-record table `record:line_items`, `element:text`, `element:image`, `element:divider`, `element:definition-list`, `element:repeater`, `element:number` and `object-metric`; `os validate` / `os build` / `os lint` and the metadata save door refuse any other block inside it. Consumer: the console's browser print rendering, and later the server-side PDF renderer; until it ships, the declaration is validated but nothing applies it. A list view's print button is `allowPrinting`, not this."), /** * Override semantics for record pages. From 9077995aeecb11be6e1112d0dd332c9ce6bcda33 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 06:04:28 +0000 Subject: [PATCH 12/15] chore(spec): regenerate the page reference for the print describe naming its admitted page types Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- content/docs/references/ui/page.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 66508a2b4cf..ceba6c3c3cb 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -181,7 +181,7 @@ View filter rule | **assignedProfiles** | `never` | optional | [REMOVED] `page.assignedProfiles` was removed in @objectstack/spec 17.5.0 (ADR-0090 D2, ADR-0049 enforce-or-remove) — it was named for the Profile concept ADR-0090 D2 deleted, and it gated nothing: no renderer, route or metadata read door ever read the key, so a page that "assigned profiles" stayed open to every caller who could reach it. Delete the key. Page audience is the permission set's: gate the DATA the page shows with the object's permission sets, and bind those sets to people through positions (`sys_position_permission_set`) — those are the checks the runtime actually runs. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **interfaceConfig** | `{ source?: string; columns?: string[] \| object[]; sort?: object[]; filterBy?: object[]; … }` | optional | Interface-level page configuration (for Airtable-style interface pages) | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | -| **print** | `{ paperSize?: Enum<'A4' \| 'A5' \| 'Letter' \| 'Legal'>; orientation?: Enum<'portrait' \| 'landscape'>; margins?: object; repeatHeader?: boolean; … }` | optional | Print declaration — makes this page a print page, a document authored in the page's own blocks: paper size and orientation, margins in millimetres, the running header and footer (the page's own `header` / `footer` regions), page numbers and page-break hints, each mapped to print CSS. A print page is a `kind: 'full'` page with its blocks in `regions`, and every block in it must come from the printable block subset: containers that draw every child (`page:section`, `page:card`, `page:footer`), field blocks (`record:details`, `record:highlights`), the child-record table `record:line_items`, `element:text`, `element:image`, `element:divider`, `element:definition-list`, `element:repeater`, `element:number` and `object-metric`; `os validate` / `os build` / `os lint` and the metadata save door refuse any other block inside it. Consumer: the console's browser print rendering, and later the server-side PDF renderer; until it ships, the declaration is validated but nothing applies it. A list view's print button is `allowPrinting`, not this. | +| **print** | `{ paperSize?: Enum<'A4' \| 'A5' \| 'Letter' \| 'Legal'>; orientation?: Enum<'portrait' \| 'landscape'>; margins?: object; repeatHeader?: boolean; … }` | optional | Print declaration — makes this page a print page, a document authored in the page's own blocks: paper size and orientation, margins in millimetres, the running header and footer (the page's own `header` / `footer` regions), page numbers and page-break hints, each mapped to print CSS. A print page is a `kind: 'full'` page of `type: 'record'`, `'home'` or `'app'` with its blocks in `regions`, and every block in it must come from the printable block subset: containers that draw every child (`page:section`, `page:card`, `page:footer`), field blocks (`record:details`, `record:highlights`), the child-record table `record:line_items`, `element:text`, `element:image`, `element:divider`, `element:definition-list`, `element:repeater`, `element:number` and `object-metric`; `os validate` / `os build` / `os lint` and the metadata save door refuse any other block inside it. Consumer: the console's browser print rendering, and later the server-side PDF renderer; until it ships, the declaration is validated but nothing applies it. A list view's print button is `allowPrinting`, not this. | | **kind** | `Enum<'full' \| 'slotted' \| 'html' \| 'react' \| 'jsx'>` | optional (default: `"full"`) | Page override mode. full \| slotted = structured authoring; html = author-written constrained JSX compiled (parsed, never executed) to the tree (ADR-0080; the legacy value 'jsx' is a deprecated alias), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors; react = real-React source executed at render by the runtime (ADR-0081), styled by inline `style` with the same token colors; it runs author JS, so it is gated by a host capability that defaults ON and is disabled server-side via the OS_PAGE_REACT=off env toggle. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080). | | **slots** | `{ header?: object \| object[]; actions?: object \| object[]; alerts?: object \| object[]; highlights?: object \| object[]; … }` | optional | Slot override map for slotted pages | | **source** | `string` | optional | Page source text. For kind==='html' (alias 'jsx') it is constrained JSX compiled to the tree by @objectstack/sdui-parser at save time (parse, never execute), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors. For kind==='react' it is real React/JSX executed at render by @object-ui/react-runtime (trusted tier), styled by inline `style` with the same token colors. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080). Authoritative over `regions` in both. | From 0b96e61e15d26cf5936a39d5904db5e07bbc9ce5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 06:24:43 +0000 Subject: [PATCH 13/15] =?UTF-8?q?test(spec):=20the=20isomorphic=20pin=20co?= =?UTF-8?q?unt=20is=20773=20=E2=80=94=20PagePrintSchema's=20pin,=20counted?= =?UTF-8?q?=20once=20its=20neighbour's=20space=20came=20back?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PagePrintSchema pin added one isomorphic pin; the same edit dropped the space in the untouched PageTypeSchema line, whose '= Assert<' the count's pattern then missed, so the count read 772 and stayed green by accident. Restoring the space made it 773; the literal, the case title and the header's prose follow, with the arrow entry naming why. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- .../spec/src/type-alias-convention.pin.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index fe9955a5a20..5081815d0c7 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -274,7 +274,7 @@ import type * as M187 from './shared/duration.zod.js'; import type * as M188 from './ai/build-progress.zod.js'; // --------------------------------------------------------------------------- -// 772 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 773 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1671,7 +1671,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 772 isomorphic pins', () => { + it('still declares all 773 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2424,7 +2424,16 @@ describe('ADR-0122 type-alias convention', () => { // `ObjectFormPropsSchema` left the isomorphic family for an // `ObjectFormPropsParsed` alias, the route the object-* family note above // prescribes. -1 removed. - expect(pins).toHaveLength(772); + // + // 772 -> 773 is #22158: the page `print` declaration's new exported + // `PagePrintSchema` has no default and no transform in its tree, so it is + // pinned here (Iso_ui_page__PagePrintSchema) rather than given a + // `PagePrintParsed` synonym. +1 added. ⚠️ The first commit carrying that pin + // read 772 here and stayed green by ACCIDENT: the same edit dropped the space + // in the untouched `Iso_ui_page__PageTypeSchema = Assert<` line, which this + // case's `= Assert<` pattern then no longer matched — one pin added, one + // pin silently uncounted. Restoring the space is what made the count honest. + expect(pins).toHaveLength(773); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either From 5f68ebe482190ab4853a91a07282a3f6bdff99f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 09:18:55 +0000 Subject: [PATCH 14/15] chore(spec): regenerate the page reference on the merged tree The merge script's collection point: content/docs/references/ui/page.mdx regenerated from the merged sources, so main's --write sentence and this branch's print describe compose. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- content/docs/references/ui/page.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index ceba6c3c3cb..9238ed8128a 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -178,7 +178,7 @@ View filter rule | **template** | `string` | optional (default: `"default"`) | Layout template name (e.g. "header-sidebar-main") | | **regions** | `{ name: string; width?: Enum<'small' \| 'medium' \| 'large' \| 'full'>; components: object[] }[]` | optional | Layout regions (header, main, sidebar, footer) with their components. Optional — list pages use interfaceConfig, slotted pages use slots, and an empty full page falls back to the synthesized default layout. | | **isDefault** | `boolean` | optional (default: `false`) | | -| **assignedProfiles** | `never` | optional | [REMOVED] `page.assignedProfiles` was removed in @objectstack/spec 17.5.0 (ADR-0090 D2, ADR-0049 enforce-or-remove) — it was named for the Profile concept ADR-0090 D2 deleted, and it gated nothing: no renderer, route or metadata read door ever read the key, so a page that "assigned profiles" stayed open to every caller who could reach it. Delete the key. Page audience is the permission set's: gate the DATA the page shows with the object's permission sets, and bind those sets to people through positions (`sys_position_permission_set`) — those are the checks the runtime actually runs. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **assignedProfiles** | `never` | optional | [REMOVED] `page.assignedProfiles` was removed in @objectstack/spec 17.5.0 (ADR-0090 D2, ADR-0049 enforce-or-remove) — it was named for the Profile concept ADR-0090 D2 deleted, and it gated nothing: no renderer, route or metadata read door ever read the key, so a page that "assigned profiles" stayed open to every caller who could reach it. Delete the key. Page audience is the permission set's: gate the DATA the page shows with the object's permission sets, and bind those sets to people through positions (`sys_position_permission_set`) — those are the checks the runtime actually runs. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **interfaceConfig** | `{ source?: string; columns?: string[] \| object[]; sort?: object[]; filterBy?: object[]; … }` | optional | Interface-level page configuration (for Airtable-style interface pages) | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | | **print** | `{ paperSize?: Enum<'A4' \| 'A5' \| 'Letter' \| 'Legal'>; orientation?: Enum<'portrait' \| 'landscape'>; margins?: object; repeatHeader?: boolean; … }` | optional | Print declaration — makes this page a print page, a document authored in the page's own blocks: paper size and orientation, margins in millimetres, the running header and footer (the page's own `header` / `footer` regions), page numbers and page-break hints, each mapped to print CSS. A print page is a `kind: 'full'` page of `type: 'record'`, `'home'` or `'app'` with its blocks in `regions`, and every block in it must come from the printable block subset: containers that draw every child (`page:section`, `page:card`, `page:footer`), field blocks (`record:details`, `record:highlights`), the child-record table `record:line_items`, `element:text`, `element:image`, `element:divider`, `element:definition-list`, `element:repeater`, `element:number` and `object-metric`; `os validate` / `os build` / `os lint` and the metadata save door refuse any other block inside it. Consumer: the console's browser print rendering, and later the server-side PDF renderer; until it ships, the declaration is validated but nothing applies it. A list view's print button is `allowPrinting`, not this. | @@ -271,7 +271,7 @@ View filter rule | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.`. The shipping renderer additionally mounts `features`, `os.user` and binds `data` to the data-source ADAPTER here — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). ⚠️ `data` is surface-dependent: on a `page:tabs` item `visibleWhen` it is the record ROW instead. e.g. "page.selectedProjectId != ''" | | **visibility** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. | | **dataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … }` | optional | Per-element data binding for multi-object pages | -| **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | ### Nested Shape: `PageComponent.responsiveStyles` @@ -397,7 +397,7 @@ Print declaration: paper size, orientation, margins, the running header and foot | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.`. The shipping renderer additionally mounts `features`, `os.user` and binds `data` to the data-source ADAPTER here — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). ⚠️ `data` is surface-dependent: on a `page:tabs` item `visibleWhen` it is the record ROW instead. e.g. "page.selectedProjectId != ''" | | **visibility** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. | | **dataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … }` | optional | Per-element data binding for multi-object pages | -| **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | From 9f9fa3dba1c6046ce9dffc8f385c73aaae367c5f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 09:20:46 +0000 Subject: [PATCH 15/15] chore(changeset): grade the document-family retirement major under Changesets pre mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main entered pre mode (tag next) with the v18 opening marker; the no-major guard stands aside in pre mode, so the export removal is graded what it is. @objectstack/lint stays minor. Clause-② yes (narrowing) unchanged: the print key and the new exports widen, the retirements narrow. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- .changeset/22158-print-page-spec.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/22158-print-page-spec.md b/.changeset/22158-print-page-spec.md index b7039021a24..80101156100 100644 --- a/.changeset/22158-print-page-spec.md +++ b/.changeset/22158-print-page-spec.md @@ -1,5 +1,5 @@ --- -'@objectstack/spec': minor +'@objectstack/spec': major '@objectstack/lint': minor --- @@ -9,7 +9,7 @@ Clause-②: yes (narrowing) -**BREAKING** — the document family's exports leave `@objectstack/spec/data` (an export removal on a published entry), shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not yet in on `main`). The `print` declaration itself is additive. +**BREAKING** — the document family's exports leave `@objectstack/spec/data` (an export removal on a published entry), graded `major` on `@objectstack/spec`: Changesets is in pre mode on `main` (tag `next`), where the launch-window `major` guard stands aside for the line's breaking changes, so the level says what the change is. The v18 opening marker already takes the fixed group to `18.0.0-next.N`, so this grade moves no version on its own. `@objectstack/lint` is `minor`: its new rule refuses blocks only inside a page that declares `print`, which no page could carry before this release, and it adds exports. The `print` declaration itself is additive. Card ① of the ruling on PDF and print documents (letter B′): **a document is a page with a print declaration; there is no separate template type.**