Repository navigation
Commit ef65646
feat(spec): an app page binds its row as
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>record, so visibleWhen can follow a bound count or a plan field (#24275)1 parent 8bfb17d commit ef65646
17 files changed
Lines changed: 313 additions & 30 deletions
File tree
- .changeset
- content/docs
- references
- ui
- ui
- docs/audits/2026-07-unknown-key-strictness-ledger.counts
- packages
- metadata-protocol/src
- spec
- api-surface
- authorable-surface
- declaration-map
- export-origins
- json-schema.manifest
- liveness
- state-counts
- src
- system
- ui
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
36 | | - | |
37 | | - | |
| 36 | + | |
| 37 | + | |
38 | 38 | | |
39 | 39 | | |
40 | 40 | | |
| |||
364 | 364 | | |
365 | 365 | | |
366 | 366 | | |
367 | | - | |
| 367 | + | |
368 | 368 | | |
369 | 369 | | |
370 | 370 | | |
| |||
381 | 381 | | |
382 | 382 | | |
383 | 383 | | |
384 | | - | |
| 384 | + | |
385 | 385 | | |
386 | 386 | | |
387 | 387 | | |
| |||
0 commit comments