Skip to content

Commit ef65646

Browse files
feat(spec): an app page binds its row as record, so visibleWhen can follow a bound count or a plan field (#24275)
Fixes #24265 Clause-②: yes (widening) ## Summary An `app` page can now declare the row it is about: `PageSchema.record`, a one-row query `{ object, view?, filter?, sort? }`. Every component `visibleWhen` on the page reads that row as `record`, which is already a contract-bound root on this surface. The count and the tier are both fields on that row: a `summary` roll-up and the bound object's own plan field. **No new expression root is added**, the lint's declared roots do not move, and nothing only the control plane could fill is named. The renderer half is objectstack-ai/objectui#12931. Until the console mounts the binding, `record` stays unbound on an `app` page, exactly as before. The liveness ledger grades the key `planned`, citing the parse-time refusal as its code pointer. ## The card's three questions, measured All objectstack readings are on `origin/main` `a422f04fe`. All objectui readings are on objectui `main` `79cb8c03eb`. ### Q1: the root for an `app` page's bound row → the existing `record`, given a page-level binding Measured: - `SchemaRenderer`'s `configEvaluationScope` (`packages/react/src/SchemaRenderer.tsx`) binds `record` from `RecordContext` (`boundRecord = recordContext?.data`) and binds `page` from page variables. It binds nothing else. - On an `app` page, the only provider of that context is a record block's own one-row binding (`plugin-detail/src/renderers/recordBinding.tsx`, `BoundRecordScope`). That provider wraps the block's **body**, so neither the block's own `visibleWhen` nor a sibling's ever sees the row. H2 holds: "the row its page binds" was not a page-level concept at all. What actually existed was "a row one block binds, private to that block". - `PageVariableSchema.source` names an *interactive writer*, such as `element:record_picker` or `element:text_input`, which write a selection. No block publishes data into a variable (H3, measured). | Option | Business need | Long-term | Hard for AI to get wrong | Startup focus | |---|---|---|---|---| | **A. `PageSchema.record` → existing `record` root** (chosen) | Serves the ruled pull directly: the environments page binds the organisation row, and both the count and the tier are fields on it | Same root ADR-0089 D1 names for runtime surfaces. Reuses the record blocks' one-row semantics (first row by `sort`, read as the caller, re-read on write), so the renderer gets no second path | Strict shape. `limit`, `id` and `recordId` are refused with prescriptions. The key is refused on every non-`app` page. Still a `planned` window until objectui#12931 lands, the same posture as `ElementApiDataSourceSchema` and the former `print` row | One key that reuses an existing shape. No new root, no lint change, no new gate | | B. A bound block publishes its row into `page.NAME` | Same reach, but only when a block that shows the row exists | Changes what `source` means (selection writer → data publisher). A variable is empty until the block loads, which gives a second timing model | Two writers with two meanings under one key | Needs renderer work in every row-bearing block | | C. A new root (`bound.*` or similar) | Same reach | A third row spelling beside `record` and `data`, against ADR-0089 D1's per-layer root | A new root the lint, formula and editor must all learn | Widest surface; the card prefers not to add one | ### Q2: tier without a cloud-only root → a field on the bound row, **not** `features` Measured (H4): - `features.X` already passes the lint, because `features` is in `@objectstack/formula`'s `SCOPE_ROOTS`. - objectui fills `features` from `/api/v1/auth/config` `.features`, in `AppContent.tsx` and `RecordFormPage.tsx`. - That object is `PUBLIC_AUTH_FEATURES` (`packages/spec/src/kernel/public-auth-features.ts`). It is a closed, drift-guarded registry of **deployment-level** auth-capability flags, served to anonymous clients by plugin-auth's `getPublicConfig()`. - It is the same for every organisation and every viewer. Server-side, `@objectstack/formula`'s `buildScope` does not fill it at all. So an organisation's plan in `features` would need a per-organisation flag that open plugin-auth cannot know. Only the control plane could fill it, which is exactly the card's ⛔. Promoting `features` to contract-bound would be spec text only, but it would still not carry a tier. The answer is `record.plan != 'free'` (or whatever field the bound object carries). The `visibleWhen` docblock, its describe and the pages guide now say outright that a plan is not `features.*`. `features` stays ambient and unpromoted. | Option | Business need | Long-term | Hard for AI to get wrong | Startup focus | |---|---|---|---|---| | **A. A field on the bound row** (chosen) | The plan is data the control plane already owns on its organisation row | The platform supplies the mechanism and the app supplies the field, which matches the app/platform boundary | One place to read row data (`record`). Docs refuse the `features` reading by name | No new surface | | B. Promote `features`, map the plan to flags | Would need a per-org, per-session flag in a deployment-level anonymous registry | Breaks the registry's meaning, and the drift guard ties every key to `getPublicConfig()` | An AI reads `features` as deployment config and the plan as org data, two meanings under one root | Cloud-only fill, which the card forbids | ### Q3: the count → a `summary` roll-up on the bound row, through Q1's root `summary` is a spec field type (`data/field.zod.ts`). "A second environment exists" is `record.environment_count > 1` on the organisation row. This needs no root and no spec change beyond Q1. The docblock names the pattern. ### ADR check (H5) - **ADR-0068 D1** governs the user object only. Its Non-goals fence `record` / `org` / `env` namespaces out. Nothing here touches `current_user`. - **ADR-0089 D1**: runtime record surfaces bind `record`. That is the root this uses, unchanged. Neither ADR is contradicted. ## What changed - `packages/spec/src/ui/page.zod.ts`: - `PageRecordBindingSchema` (strict; aliases plus `limit` / `id` / `recordId` guidance). - `PageSchema.record` with aliases (`boundRecord`, `recordBinding`, `recordSource`, `dataSource`). - `checkPageRecordBindingPlacement`: refuses `record` unless `type: 'app'`, with a separate sentence for a record page. It is exported and attached by identifier like the six sibling checks. - The `visibleWhen` docblock and describe: the row's meaning, the sibling limit, and count/plan as fields, not `features`. - `PageRecordBinding` / `PageRecordBindingParsed` type aliases. - The schema is declared after `PageComponentSchema`. That ordering keeps the generated `skills/objectstack-ui/references/_index.md` (a governed path) byte-identical. - Tests: - `object-refinement-check-exports.test.ts` adds the new export: six fixtures (app accept, no binding, home, record, untyped default, list), attachment counts and barrel identity. - `metadata-form-zod-reconciliation.test.ts` adds a root ledger entry for `record` (planned, on the `print` precedent). - Ledgers: - `liveness/page.json` adds a `record` row, `planned`, drilled to `object` / `view` / `filter.{field,operator,value}` / `sort.{field,order}`. - `dropped-refinements.baseline.json` gets the new `ui/PageRecordBinding` site, `ui/Page`'s first `ViewFilterRule` site moved from `interfaceConfig.filterBy` to `record.filter`, and totals 237 / 768. - Generated by their generators: `authorable-surface`, `api-surface`, `export-origins`, `declaration-map`, `json-schema.manifest`, `liveness/state-counts`, the strictness-ledger counts, and `content/docs/references/**`. - `content/docs/ui/pages.mdx`: the `record` property row and the `visibleWhen` row. - `.changeset/24265-app-page-record-binding.md`: `@objectstack/spec` minor, with the `Clause-②` line. Not touched: `packages/lint` (the root set did not move: `record` and `page` were already declared), `packages/formula`, objectui, `.objectui-sha`, `rule-explanations.ts`, `component.zod.ts`, `content/docs/releases/`. ## Verification These runs were on head `a150ec532f`. CI then went red there in `@objectstack/metadata-protocol`: the census in `protocol.meta-types-degenerate-derivation.test.ts` pinned 25 top-level `page` properties. Patch round 1 (`03b3e5ad4b`, report `6247697989`) moved that census to 26 for `record`. The merge of `main` after PR #24270 (`6480ad0b6`, with its regeneration commit `88dbe419`) followed. The head's check-runs are its verification. Contract review PASS `6248097604` read that head. - `pnpm --filter @objectstack/spec build` exited 0. `check:generated` reported "All 14 generated artifacts are up to date". - `pnpm --filter @objectstack/spec test` (local project): 693 files, 21015 passed, 1 todo. - `pnpm --filter @objectstack/spec test:repo`: 68 files, 1125 passed. - `pnpm --filter @objectstack/spec typecheck` exited 0. - `node scripts/pm/dispatch-gates.mjs --commands`: 111 derived commands, all run, all exit 0. Seven first exited 3 on prerequisites (an unbuilt closure, a shallow clone). They exited 0 after `pnpm --filter '@objectstack/lint...' --filter '@objectstack/client-react...' --filter '@objectstack/objectql...' build`, a full `turbo run build` and `git fetch --unshallow`. `--ran` reconciliation: 111 derived, 111 run, 0 NOT-MEASURED, 0 UNRUN. - `check-changeset-no-major --event` with this body: no major bump, and the level axis accepts `Clause-②: yes (widening)` with `@objectstack/spec` at `minor`. `scripts/pm/clause2-line.mjs` reads the same line from this body and from the changeset. - `check:liveness` is green, with the `record` row `planned` and drilled. - Ablation, run through `scripts/ablation-replace.mjs` with a trap restore. The `type === 'app'` accept arm of `checkPageRecordBindingPlacement` was changed so it can never match. `object-refinement-check-exports.test.ts` then failed exactly the accepting fixture ("a `record` binding on an `app` page": expected `[ 'record' ]` to deeply equal `[]`, 1 failed / 251 passed). The file was restored with blob == HEAD and an empty `git diff HEAD`. ## Acceptance notes - `content/docs/ui/pages.mdx`'s `visibleWhen` row used to say "Legacy alias `visibility` is accepted but deprecated per ADR-0089". At 18 the key is a `retiredKey()` tombstone. Patch round 1 corrected the row: the retired spelling is refused at parse (ADR-0089 D2). This was review flag F1. - No lint finding is added for `record.*` read on an `app` page that declares no binding. Adding a gate defaults to no, and the predicate faults open there exactly as before. --- _Generated by [Claude Code](https://claude.ai/code/session_012SGQNHx8abCKZKoyj2VQym)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_012SGQNHx8abCKZKoyj2VQym --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8bfb17d commit ef65646

17 files changed

Lines changed: 313 additions & 30 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
Clause-②: yes (widening)
6+
7+
feat(spec): an app page binds its row as `record`, so a component `visibleWhen` can follow a bound count or a plan field
8+
9+
- **What it adds.** `PageSchema.record` (`PageRecordBindingSchema`): a one-row query `{ object, view?, filter?, sort? }` on a `type: 'app'` page. The page's row is the FIRST row of that query, read under the caller's identity, and every component predicate on the page reads it as `record`, the root ADR-0089 D1 already names for runtime surfaces. No new expression root is added.
10+
- **What a predicate reads.** A count or a plan is a field on the bound row: a `summary` (roll-up) field (`record.environment_count > 1`) or the object's own field (`record.plan != 'free'`). `features.*` is not a plan: that root is the deployment's public auth flags (`/api/v1/auth/config`), the same for every organisation and every viewer. The `visibleWhen` docblock and describe now say so, and they say that a block's own element `dataSource` binds no `record` for its siblings or for its own `visibleWhen`.
11+
- **What it refuses.** `record` on any page that is not `type: 'app'` is refused at parse (`checkPageRecordBindingPlacement`, exported from `@objectstack/spec/ui` like the other page checks): a record page's row is the record it opens, and a `home` / `list` / `utility` page has no row. Inside the binding, `limit` is refused with a prescription (a page binds one row; say which with `sort` or `filter`), and so are `id` / `recordId` (match the row with a `filter` rule on `id`).
12+
- **Planned in the renderer.** The console does not mount the binding yet: until it does, `record` stays unbound on an app page, as before, and a predicate reading it faults, so the component renders. The liveness ledger grades the key `planned`.
13+
- **Unchanged:** every existing page, component and predicate; the predicate root set (`record`, `current_user` with its aliases, `page.NAME`); the lint's declared roots.

‎content/docs/references/index.mdx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Protocol reference — every schema by module
33
navTitle: Protocol Reference
4-
description: Every schema published by @objectstack/spec — 1538 schemas across 14 protocol modules
4+
description: Every schema published by @objectstack/spec — 1539 schemas across 14 protocol modules
55
---
66

77
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -33,8 +33,8 @@ counts are sums of the rows they head. Regenerate with
3333
| [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. |
3434
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
3535
| [System Protocol](/docs/references/system) | 36 | 284 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
36-
| [UI Protocol](/docs/references/ui) | 16 | 168 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
37-
| **Total** | **197** | **1538** | 14 protocol modules |
36+
| [UI Protocol](/docs/references/ui) | 16 | 169 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
37+
| **Total** | **197** | **1539** | 14 protocol modules |
3838

3939
---
4040

@@ -364,7 +364,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a
364364

365365
## UI Protocol
366366

367-
**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 168 schemas**
367+
**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 169 schemas**
368368

369369
Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer.
370370

@@ -381,7 +381,7 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay
381381
| [`expression-bindable-text-keys.zod.ts`](/docs/references/ui/expression-bindable-text-keys) | `ExpressionBindableTextKey` |
382382
| [`i18n.zod.ts`](/docs/references/ui/i18n) | `AriaProps`, `I18nLabel`, `InlineLocaleMap` |
383383
| [`notification.zod.ts`](/docs/references/ui/notification) | `NotificationPosition`, `NotificationSeverity`, `NotificationType` |
384-
| [`page.zod.ts`](/docs/references/ui/page) | `ElementApiDataSource`, `ElementDataSource`, `InterfacePageConfig`, `Page`, `PageComponent`, `PageComponentType`, `PagePrint`, `PageRegion`, `PageTemplate`, `PageType`, `PageVariable` |
384+
| [`page.zod.ts`](/docs/references/ui/page) | `ElementApiDataSource`, `ElementDataSource`, `InterfacePageConfig`, `Page`, `PageComponent`, `PageComponentType`, `PagePrint`, `PageRecordBinding`, `PageRegion`, `PageTemplate`, `PageType`, `PageVariable` |
385385
| [`report.zod.ts`](/docs/references/ui/report) | `JoinedReportBlock`, `Report`, `ReportChart`, `ReportSort`, `ReportType` |
386386
| [`responsive.zod.ts`](/docs/references/ui/responsive) | `ResponsiveStyles`, `StyleMap` |
387387
| [`sharing.zod.ts`](/docs/references/ui/sharing) | `SharingConfig` |

0 commit comments

Comments
 (0)