Repository navigation
chore(objectui): bump the console pin to 31971ff1e28f (one zod instance in the vendored Console), add a single-zod canary to build-console.sh, and key the release console cache on the spec zod range - #21149
Conversation
…ole changeset, lockstep record) scripts/bump-objectui.sh 31971ff1e28f --no-commit against a private full objectui clone, then pnpm gen:sdui-lockstep against the pin's checkout. The ADR-0087 disposition placeholder is answered in a later commit. Claude-Session: https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7 Co-authored-by: Claude <noreply@anthropic.com>
…ase console cache key carries the spec's zod range build-console.sh asserts exactly one zod version literal across the shipped assets/*.js, beside the bundle canary, and exposes the same check as `--check-single-zod <assets-dir>` so it can be run over an existing dist. release.yml's Console dist key carries packages/spec's declared zod range, read out of package.json rather than hashing the file, whose version moves on every Version Packages merge. Claude-Session: https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7 Co-authored-by: Claude <noreply@anthropic.com>
…t 1) Claude-Session: https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7 Co-authored-by: Claude <noreply@anthropic.com>
…t 2) Claude-Session: https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7 Co-authored-by: Claude <noreply@anthropic.com>
…; re-measure pin citations (WIP, part 3) node scripts/gen-sdui-manifest-node.mjs over the tree build-console.sh built at the pin: 107 components, @object-ui/core 17.6.0. Claude-Session: https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7 Co-authored-by: Claude <noreply@anthropic.com>
…t 4) Claude-Session: https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7 Co-authored-by: Claude <noreply@anthropic.com>
…answer the console changeset's ADR-0087 disposition Every asserting pin citation in packages/spec/src re-measured at the new pin; migrations/registry.ts regenerated (gen:migration-registry). The console changeset's adr-0087 placeholder is answered not-required (no-migration-prescription), worded after the previous bump's ruling. Claude-Session: https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7 Co-authored-by: Claude <noreply@anthropic.com>
…n (check:generated --fix) Claude-Session: https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7 Co-authored-by: Claude <noreply@anthropic.com>
…jectui-pin-single-zod
…jectui-pin-single-zod component.zod.ts conflicted in two docblocks; both intents stack: main's #21005 (action:button / action:icon refuse `endpoint`) and #21141 (the quickAdd tombstone prescribes "delete the key") are kept verbatim, and the pin bump's citation updates at 31971ff1e are re-applied on top (`inputs` `:414-559` and the slice-2 `size` sentence; `e420df310` in the kanban-ui registration count). Regeneration follows as its own commit. Claude-Session: https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 138 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin bba0bb314ae631909c98fe721003258fb1ebc2b9 && git checkout bba0bb314ae631909c98fe721003258fb1ebc2b9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2c1cef3345738b5f2486eed5b0608687dba36f11 8435a830ebfd25edcabdc012d1190d619b7dd200 && git checkout -B drift-repro 2c1cef3345738b5f2486eed5b0608687dba36f11 && git merge --no-ff 8435a830ebfd25edcabdc012d1190d619b7dd200
node scripts/docs-audit/affected-docs.mjs --json 2c1cef3345738b5f2486eed5b0608687dba36f11
|
…e policies — double accumulation, the PostgreSQL boolean cast and the empty-sum fold, hoisted into core (objectstack-ai#21042) (objectstack-ai#21209) Fixes objectstack-ai#21042 Clause-②: yes (widening) ## What this changes The analytics native-SQL strategy (`NativeSQLStrategy`, the default on a SQL driver) skipped three aggregate policies that `SqlDriver.aggregate()` applies. So one route answered different numbers, or a `500`, depending on which strategy served it. This PR follows the route ruling on objectstack-ai#21042 (comment `5925613967`): the operand policies are hoisted into `@objectstack/core`, beside `AGGREGATE_ANSWER_KIND`, and both faces read them from there. - **`packages/core/src/utils/aggregate-answer.ts`** (`@objectstack/core`, `minor`). It takes: - `AGGREGATE_ACCUMULATION`, moved from `driver-sql` with its docblock (the docblock and the table are byte-identical to the base, apart from the `export` keyword); - `aggregandColumnClass({ type, multiple })`, the one column-class predicate (`'fractional'`, `'integral'`, `'boolean'`, or none); - `POSTGRES_BOOLEAN_AGGREGAND_CAST`, the objectstack-ai#11635 cast, as a `Record` over `AggregationFunction`: `sum` / `avg` / `min` / `max` are cast, the counts never are; - `doubleAccumulationOperand(operand, dialect)`, the double operand with the dialect as a parameter; - `aggregandOperandSql(func, columnClass, dialect, operand)`, the one composition both faces emit (the cast inside, the double operand around it). No `index.ts` line was added: the module is already exported. - **`packages/drivers/driver-sql/src/sql-driver.ts`** (`patch`), in the ruled regions only. The `AGGREGATE_ACCUMULATION` table becomes a pointer plus an import. `isFractionalNumericType` is deleted. The two registry fills fill `fractionalNumericFields` through the predicate. `accumulatesInDouble` / `doubleAccumulationOperand` are replaced by `aggregandColumnClassOf`, which maps the driver's registries onto the predicate's classes. In `aggregate()`, the private boolean-cast condition and the accumulation call become one `aggregandOperandSql(funcName, class, this.dialectName, '??')`. - **`packages/services/service-analytics/src/strategies/native-sql-strategy.ts`** (`patch`), in two places. - **`resolveMeasureSql`** wraps the column it hands `AGGREGATE_SQL` / `CONDITIONAL_AGGREGATE_SQL` in `aggregandOperandSql`. The column class comes from the declaration the host already relays (`declaredValueShape`), on the object the column lives on (`columnObjectOf`, the one hop resolver). The dialect comes from `sqlDialect`, which the strategy already reads. - **The `execute` shaping point** that PR objectstack-ai#21040 added now folds a `null` measure answer to `emptyGroupValueFor(measure.type)` (`@objectstack/spec`). It does this for every measure, measure-scoped ones included, before the number presenter, in `driver-sql`'s order. The dataset door's `DatasetExecutor` fill stays, and it is idempotent on a folded row. - The one line outside those two regions is the `generateSql` call site, which now passes `ctx` to `resolveMeasureSql`. - No native copy of any policy, and no runtime hook asks the driver: the rejected (C) route was not taken. `canHandle`, `buildFieldMeta`, the hop-object sites, the filter / text-match rendering, `analytics-service.ts` and `field-read-admission.ts` are untouched. ## The card's table, before and after Measured through `AnalyticsService.query` (the cube door, which `POST /api/v1/analytics/query` relays verbatim) and `AnalyticsService.queryDataset` (the dataset door), on `AnalyticsServicePlugin` over a real ObjectQL engine and `SqlDriver`. **Native** is the plugin's own composition (`NativeSQLStrategy` answered, with one raw statement and no engine aggregate). **ObjectQL** is the same composition narrowed to `engine.aggregate`. "Before" is the strategy file at the base `d34aa58a2a`; "after" is this branch at `61aab5013a`. Neither merge since then touches the aggregate code paths, and the pins below are green at `ef1f9d8484`. Fixture: - group `f`: `frac` (a `number` column) holds 0.1 and 0.2, and `flag` holds true and false; - group `i`: `stars` (a `rating` column) holds seven 1s and two 2s, and `flag` holds 7 trues and 2 falses; - group `n`: every aggregand is NULL in all three rows. The measure-scoped measures filter on `tag = x`, which only group `f` holds. **PostgreSQL 16.13** (a private local server; the ObjectQL column is the same before and after): | measure | group | door | native before | native after | ObjectQL | |:--|:--|:--|:--|:--|:--| | `sum(frac)` | f | cube, dataset | `0.3` | `0.30000000000000004` | `0.30000000000000004` | | `avg(frac)` | f | cube, dataset | `0.15` | `0.15000000000000002` | `0.15000000000000002` | | `avg(stars)`, an integer column | i | cube, dataset | `1.222222222222222` | `1.2222222222222223` | `1.2222222222222223` | | `sum(flag)` | i | cube, dataset | `500 DATABASE_ERROR` | `7` | `7` | | `avg(flag)` | i | cube, dataset | `500 DATABASE_ERROR` | `0.7777777777777778` | `0.7777777777777778` | | `min(flag)` / `max(flag)` | i | cube, dataset | `500 DATABASE_ERROR` | `0` / `1` | `0` / `1` | | `sum(frac)`, all-NULL group | n | cube | `null` | `0` | `0` | | `sum(frac)`, all-NULL group | n | dataset | `0` (executor fill) | `0` | `0` | | `sum(flag)`, all-NULL group | n | cube | `500 DATABASE_ERROR` | `0` | `0` | | `avg(frac)`, all-NULL group | n | cube, dataset | `null` | `null` | `null` | | measure-scoped `sum(frac)`, no admitted row | i | cube | `null` | `0` | `0` | | measure-scoped `sum(frac)`, no admitted row | i | dataset | `0` (executor fill) | `0` | `0` | | measure-scoped `avg(frac)`, no admitted row | i | cube | `null` | `null` | `null` | | `count` control | n | cube, dataset | `3` | `3` | `3` | | measure-scoped `count` control | i | cube, dataset | `0` | `0` | `0` | **SQLite** (better-sqlite3): accumulation and the boolean answers already agreed on every face (`0.30000000000000004`, `0.15000000000000002`, `1.2222222222222223`, `7`, `0.7777777777777778`, `0` / `1`). The fold is the policy that diverged there: | measure | group | door | native before | native after | ObjectQL | |:--|:--|:--|:--|:--|:--| | `sum(frac)` / `sum(stars)` / `sum(flag)`, all-NULL group | n | cube | `null` | `0` | `0` | | `sum(frac)` / `sum(stars)` / `sum(flag)`, all-NULL group | n | dataset | `0` (executor fill) | `0` | `0` | | measure-scoped `sum(frac)`, no admitted row | i, n | cube | `null` | `0` | `0` | | measure-scoped `sum(frac)`, no admitted row | i, n | dataset | `0` (executor fill) | `0` | `0` | After the fix, the native and ObjectQL faces **differ in 0 of 144 cells** (2 drivers × 2 doors × 12 measures × 3 groups). **MySQL is NOT MEASURED**: there is no MySQL server in this container. The MySQL operand text is pinned offline: by `core`'s `aggregate-answer.test.ts`, and by the `driver-sql` move proof for the driver's own statements. ## The move proof `driver-sql`'s aggregate statements were dumped at the base, before any consumer changed. The dump covered `SqlDriver.aggregate()` for every function (`count`, `count_distinct`, `sum`, `avg`, `min`, `max`, and `count(*)`), aliased and unaliased, over 23 columns: every fractional, integral and boolean type, the `float` / `integer` / `int` aliases, multi-valued and untyped columns, and text / date / lookup / formula. It ran on SQLite, PostgreSQL and MySQL, through both registration paths (`registerObjectMetadata` and `registerExternalObject`), offline (knex `toSQL()`). The policies were then hoisted, `driver-sql` was switched to the imports, and the same dump was run again: - base dump: 1668 entries, 0 errors, md5 `8eee668372a28a7568f3eb1cc5a2bc9b`; - after dump (at `bc8aa0cc2f`): 1668 entries, md5 `8eee668372a28a7568f3eb1cc5a2bc9b`. `cmp` printed nothing: the two dumps are **byte-identical**. `sql-driver.ts` and `aggregate-answer.ts` are unchanged between `bc8aa0cc2f` and `61aab5013a`. The committed move-proof pin, `packages/drivers/driver-sql/src/sql-driver-21042-aggregate-policy-move.test.ts`, holds the captured expressions for one column of each class, on each dialect and through each registration path. It passed at the base (`192fc0010b`: 54 / 54) and passes after (54 / 54). ## Pins (committed red first, then the fix) | file | at the pins commit (`192fc0010b`, base code) | after | |:--|:--|:--| | `core` `aggregate-answer.test.ts` | 16 red (the exports did not exist) | 22 / 22 | | `service-analytics` `native-sql-aggregate-policies.test.ts` (each measure on both faces at both doors, against the engine's arithmetic; SQLite and live PostgreSQL cells) | SQLite: the cube-door folds red. PostgreSQL: accumulation, boolean `500`, folds red | 49 / 49 | | `service-analytics` `cube-measure-field-type-door.test.ts`, **the lifted skip** | PostgreSQL native `max(boolean)` red (`500`) | 23 / 23 | | `rest` `analytics-dataset-aggregate-policies-door.test.ts` (the route, both strategies) | PostgreSQL: 7 red (accumulation and booleans). SQLite green (that door already folded) | 19 / 19 | | `driver-sql` move proof | 54 / 54 | 54 / 54 | **The lifted skip:** `it.skipIf(cell.id === 'pg' && face === 'native')` in `cube-measure-field-type-door.test.ts` (from PR objectstack-ai#21128) is gone. Its comment now says why the cell runs on every cell and face. The PostgreSQL native `max_flag` cell answers `1`. `native-sql-measure-number-presentation.test.ts` gets a comment-only edit: its header said the native statement does not carry objectstack-ai#20387's accumulation, and it now points at the new pin. ## Ablations There was one ablation per policy, each predicted in writing before it ran. Each mutation was planted through `scripts/ablation-replace.mjs`, which checks that the anchor hit and that the blob changed. Each mutation was confirmed in the built `dist/` (`ablation-dist-preflight.mjs`: marker present). Each restore ran by absolute path (`git checkout HEAD`), and the file's blob was proven equal to its `HEAD` blob with `git diff HEAD` empty. After each restore the package was rebuilt, and the marker was proven absent from `dist/` with the tree clean. Every prediction held exactly. | ablation | mutation | predicted red | observed red | |:--|:--|:--|:--| | A1 accumulation | `core` `accumulatesInDouble`'s dialect gate never admits PostgreSQL or MySQL | `core` 3; `driver-sql` move proof 24 (pg and mysql × both fills × the six numeric / boolean columns); `service-analytics` 10, PostgreSQL only (both doors × `sum` / `avg(frac)`, `avg(stars)`, measure-scoped `sum` / `avg`); `rest` 3, PostgreSQL only | the same 3 / 24 / 10 / 3; SQLite cells green; `avg(flag)` green as predicted | | A2 boolean cast | `core` `aggregandOperandSql` never casts | `core` 1; move proof 4 (pg × both fills × `boolean` / `toggle`); `service-analytics` 8, PostgreSQL only; the lifted cell, PostgreSQL native and ObjectQL, 2; `rest` 4, PostgreSQL only | the same 1 / 4 / 8 / 2 / 4 | | A3 fold | the native shaping point never folds | `service-analytics` 8, cube door only (SQLite and PostgreSQL × the three all-NULL `sum`s and the measure-scoped `sum`); everything else green, the `rest` dataset-door route included, because the executor fill folds there | the same 8; `rest` 19 / 19 green | A1 and A2 show one policy reaching both faces. Each one turned `driver-sql`'s own statements red. Under A2 the ObjectQL face's `max(boolean)` cell failed too, with the driver's refusal. Under A1 the ObjectQL face answered the same exact decimal as the native face for the plain measures: the failing assertion was the engine's number, while native and ObjectQL still agreed. A **reverse type check** also ran. Passing a dialect the new type rejects (`'oracle'`) to `aggregandOperandSql` turned `service-analytics`' typecheck red (`TS2345 ... not assignable to parameter of type 'AggregandSqlDialect'`), which shows the rebuilt `core` `.d.ts` was read. The file was restored byte-identical. ## Verification (at `ef1f9d8484`, after merging `origin/main` at `cb45469e67`, which carries PR objectstack-ai#21170 and PR objectstack-ai#21173) Everything below ran as one locked script, at `ef1f9d8484`, with each exit code captured before any pipe. The live PostgreSQL 16.13 server ran at `timezone = Asia/Shanghai`, and the `driver-sql` suite ran under `TZ=America/New_York`, which are its own non-vacuity preconditions. - **Refresh after the merge:** `pnpm turbo run build --filter='!@objectstack/docs' --concurrency=1` exit 0, and `pnpm --filter @objectstack/spec check:generated` exit 0. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 67 commands from the real diff (10 paths). All 67 exited 0. Reconciliation with `--ran` and the recorded exit codes printed: `Run reconciliation — 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN.` - **Typecheck:** `pnpm --filter … typecheck` exit 0 for `@objectstack/core`, `@objectstack/driver-sql`, `@objectstack/service-analytics` and `@objectstack/rest`. - **Tests, every vitest project of every touched package** (`vitest run --maxWorkers=2`, with `OS_TEST_POSTGRES_URL` set so the live cells ran): | package / project | files | tests | |:--|:--|:--| | `core` local | 74 passed | 2120 passed | | `core` repo | 3 passed | 48 passed | | `driver-sql` | 216 passed, 3 skipped | 4300 passed, 96 skipped | | `service-analytics` | 161 passed | 3765 passed | | `rest` local | 256 passed | 5027 passed, 127 skipped | | `rest` repo | 5 passed | 179 passed, 1 skipped | - **The five pin files, run explicitly:** move proof 54 / 54, `core` 22 / 22, policies 49 / 49 (24 SQLite + 24 PostgreSQL + the oracle), field-type door 23 / 23, `rest` route 19 / 19 (9 SQLite + 9 PostgreSQL + the oracle). - **Lint, narrowed and proven:** `eslint --no-inline-config --format json` over the 9 changed code files answered 9 results, 0 errors and 0 warnings. The population is read from eslint's own config: `ESLint.isPathIgnored` answers `false` for each of the 9. The narrowing excludes nothing that could move, because `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot change the verdict on an untouched file. The repo-wide `pnpm lint` is CI's. - **The `driver-sql` live preconditions:** the first gate run used a private server at UTC, and the suite's four timezone non-vacuity cells failed by design (`… start it with timezone=Asia/Shanghai`). With the server at `Asia/Shanghai` and the process at `America/New_York` the suite is green, as listed above. - **`main` after the final merge:** `origin/main` moved 4 commits past `cb45469e67` before this PR opened (objectstack-ai#21149, objectstack-ai#21188, objectstack-ai#21195, objectstack-ai#21192). None of them touches `core`, `driver-sql`, `service-analytics` or the analytics `rest` tests. The one `packages/spec` file in the analytics area, `ui/dataset.zod.ts`, changes a comment only. They are not merged here; CI runs on the merge ref. ## Acceptance notes - **MySQL is NOT MEASURED** (no server in this container). The MySQL operand text is pinned offline in `core` and in the `driver-sql` move proof. - **The live PostgreSQL cells are not run in CI.** No CI step sets `OS_TEST_POSTGRES_URL` for `service-analytics` or `rest`. The cells above ran against a private PostgreSQL 16.13 started for this run and removed afterwards. In CI the SQLite cells run, and so do the offline `core` / move-proof pins. - **Phase 0's note on the dataset door, which is no divergence:** a measure-scoped `avg` is **absent** from a row its supplementary query reported no row for. That is `x_avg_frac` for groups `i` and `n`, on both strategies and before and after. It is not `null`. The new service pin holds this cell only to "both faces agree", not to a value. Relatedly, a dataset-door selection made only of measure-scoped measures reports only the groups their filter admits, so the pin asks each one beside the base count. - **Residual, as stated in the ruling:** a host that relays no field declarations (`declaredValueShape`), or names no SQL dialect, gets no column class or no policy. It keeps the native arithmetic it had, and a PostgreSQL boolean `sum` there still answers `500`. The plugin's own composition wires both. - **How `driver-sql` reads the class:** it reads its own registries rather than calling the predicate per column. `fractionalNumericFields` is filled by the predicate. `booleanFields` and `numericFields` are filled by the driver's coercion rules, whose populations equal the predicate's `'boolean'` and `'fractional'` ∪ `'integral'` classes. The move proof pins that equality per column class. Asking the predicate per column through the driver's `valueShapeFields` would retire `fractionalNumericFields`, but its declaration and shard-alias regions are outside the ruled surface, so this PR does not do it. - **The scan-order residual is unchanged.** On PostgreSQL and MySQL the double sums are added without compensation (`AGGREGATE_ACCUMULATION`'s docblock), so three or more fractions can still differ in the last place from SQLite and the rows path. The pins use two addends. - objectstack-ai#21129 (the presenter for a relationship-path `min` / `max`) is not addressed here. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…nonymous lookup route (objectstack-ai#21180) (objectstack-ai#21222) Fixes objectstack-ai#21180 Clause-②: yes (narrowing) Retires the form field's `publicPicker` block and deletes the anonymous lookup route `GET /forms/:slug/lookup/:field`. This is the second half of ruling E on objectstack-ai#21079 (record `5933054144`, maintainer 「同意E」, 2026-10-01). Anonymous public forms no longer take lookup, `master_detail` or `user` fields. The ruling reverses the objectstack-ai#7467 model ("declare `publicPicker`"). It is an immediate retirement under ADR-0087 D2: no alias window and no dual spelling. No new gate: the resolve route's existing strip becomes unconditional. The ADR-0061 note rides a separate docs-only PR (Tier H), not this one. ## What changed | surface | change | | --- | --- | | spec | `FormFieldBaseSchema.publicPicker` is a `retiredKey()` tombstone carrying the card's prescription (FROM → TO below). `FormFieldPublicPickerSchema`, `FormFieldPublicPicker` and `FormFieldPublicPickerParsed` are deleted (no other reader). | | ADR-0087 | D2 conversion `form-field-public-picker-removed` (protocol 18, `retiredFromLoadPath`, `retiredAfter: 17.5.0`, order 53). One `STEP18_RATIONALE` fragment. D3 semantic entry `form-field-public-picker-retired`. `RETIRED_KEYS_BY_MAJOR[18]` gets `ui/FormField:publicPicker` and `RETIRED_DEFS_BY_MAJOR[18]` gets `ui/FormFieldPublicPicker`. The registry is regenerated by `gen:migration-registry`, never hand-typed inside its markers. | | rest | The picker handler is deleted, with its literal `guest_portal` picker context and its picker-only helper `view-filter-rule-lowering.ts` (no other importer, not exported). The ledger row (`rest-route-ledger.ts`) goes too. The resolve route's strip loses its `publicPicker` condition: lookup / `master_detail` / `user` fields are always left off the anonymous rendering. | | error codes | `LOOKUP_NOT_PUBLIC` and `LOOKUP_TARGET_MISSING` leave `error-code-ledger.zod.ts`. This follows the ledger's own retirement rule ("A row whose last EMITTER is deleted comes out with it"), so the row is deleted, with no graded tombstone. The picker handler was the only producer of both codes. | | lint | The `publicPicker` claiming reader in `validate-preset-comparands.ts` is removed, with its cases. The now-unused `graph` parameter of `boundObjectOf` / `bindAncestors` goes too (`noUnusedParameters`). | | tests | Deleted: `public-form-lookup-picker.test.ts`, `public-form-lookup-picker-queryable-key.test.ts`, `public-form-lookup-filter-lowering.test.ts`, `view-public-picker.test.ts`, `public-picker-queryable-key.dogfood.test.ts`. Re-pinned: `public-form-routes.test.ts`, `public-form-routes.stored-row.test.ts`, `rest-server-query-number-census.test.ts`, `rest-server-canonical-query-ast.test.ts`, `view-union-branch-focus.test.ts`, `protocol.save-union-issues.test.ts`, and the picker door case of `zero-set-masking.dogfood.test.ts`. New: `form-field-public-picker-retirement.test.ts` (tombstone at four doors, the conversion, registration, and a tree-scoped absence pin over the radius already declared for `@objectstack/spec`). | | docs | The picker section of `content/docs/ui/forms.mdx` is replaced by a short statement of current behaviour. The references regenerate. The platform checklist item `access-security.public-form-intake` moves to revision 2: its 403 clause now asserts the route's absence. | | generated / ledgers | Regenerated: api-surface, export-origins, declaration-map, json-schema manifest, authorable-surface (the def's four lines deleted deliberately, the key marked `[RETIRED]`), strictness counts and references. `dropped-refinements.baseline.json` is corrected as the build printed it. `engine-double-contract.pinned.json` is regenerated with `--write`: 6 losses, all from the two deleted test files. The `check:route-envelope` pins are banked: 43 → 39 and 58 → 54, the four `{ code, error }` answers the deleted handler carried. | | changeset | `.changeset/21180-retire-public-picker.md`: BREAKING, `Clause-②: yes (narrowing)`, the card's FROM → TO, and the ADR-0087 marker `registered form-field-public-picker-removed, form-field-public-picker-retired`. | **FROM → TO:** delete the `publicPicker` block; an anonymous public form no longer offers record search. Use a `select` field with static `options`, or put the form behind sign-in. ## The PM's hypotheses, measured - **H1, the census.** At BASE `b9087d77e9`: 280 lines. By pattern: `publicPicker` 129, `FormFieldPublicPicker` 43, `LOOKUP_NOT_PUBLIC` 19, `/lookup/:field` 35, `guest_portal` 65. No producer exists outside spec, tests, docs and the REST route; no example declares one. After, at `dafa22868`: `publicPicker` 106, `FormFieldPublicPicker` 10, `LOOKUP_NOT_PUBLIC` 0, `/lookup/:field` 15, `guest_portal` 59. The kit accounts for the residue: the tombstone, the conversion and registry entries, the retirement and union pins, the H2/H3 pins, and generated artefacts (references, authorable-surface, and the base anchor, which only its own generator writes). Untouched history also remains: two dated audits, `releases/v15.mdx`, the ADR-0061 sentence, and the pending `.changeset/21062-picker-queryable-key.md`. `guest_portal` remains as permission-set names (examples, plugin-security tests, the published skill's resolve/submit text) and as the submit route's context. In `rest-server.ts` the picker's literal context was 1 of the 7 lines; the other 6 are the submit route and its docblock. All of those are outside this card. - **H2, the strip depended on the key.** It did: `if (t !== 'lookup' && t !== 'master_detail' && t !== 'user') return true; return !!cfg?.publicPicker;`. The condition is deleted; the function now returns the type test, with no new branch. Pinned by a stored row carrying the old block on a lookup, a `master_detail` and a `user` field: only the text field `subject` renders. - **H3, gone, not refused.** On the in-process `HonoHttpServer`, with the unmatched-request seam installed as `HonoServerPlugin.start()` installs it, `GET /api/v1/forms/test/lookup/owner_id` answers `404` with `error.code` `ENDPOINT_NOT_FOUND`. That body is byte-identical (path aside) to a never-registered sibling path, and `findData` is never called. The registered resolve route on the same harness answers 200, the lit control. - **H4, the ledger rule.** Deletion, per the rule's own text. The `objectui` consumer check: zero readers at the old pin `e420df310f` and the new pin `31971ff1e2` (only an exemption reason string in a parity test). The `cloud` consumer check is NOT MEASURED: code search returned zero for both the codes and a lit control (`"@objectstack/spec"`), so it cannot see that repository. - **H5, `os validate` on a fixture.** Before, at BASE: the fixture authoring `publicPicker: { displayFields: ['name'], maxResults: 10 }` gave exit 0, `valid: true`. After: exit 1, `valid: false`. The text face prints the prescription at `views.0.formViews.contact.sections.0.fields.1.publicPicker`. The control fixture, identical without the key, gives exit 0 both times. - **Pin stop condition.** objectui at `.objectui-sha` imports neither name at `e420df310f` nor, after objectstack-ai#21149 moved it, at `31971ff1e2`: 7 string mentions and 0 import lines, with a lit control of 392 files importing `@objectstack/spec/ui`. Its spec-parity test enumerates `FormFieldSchema.in.shape`, and the tombstone keeps the key there. No pin bump rides this PR. ## Reverse verification (the fix committed first, then BASE's route code restored) Run against `rest-server.ts` and the lowering module restored from BASE (blob `c673773e46`, verified on disk by hash). The pins import source, so no rebuild was needed. All three new pins went red in the expected direction: - strip: `expected [ 'subject', 'contact_id', …(2) ] to deeply equal [ 'subject' ]` - route registered: `expected true to be false` - route answer: `expected 403 to be 404` The other 20 cases in the file stayed green. Restore: back to the HEAD blob `b52e360ec3`, `git diff HEAD` empty, `git status` clean, with a trap on the script. ## Tests and gates (head `dafa22868`, after merging `main` at `5e5ce48ce`) - `@objectstack/rest`, whole package: 252 files, 4776 passed. Typecheck green, including `check:test-typecheck`. - `@objectstack/lint`, whole package (pre-merge, untouched by `main`): 118 files, 5486 passed. Typecheck green. - `@objectstack/spec`: the local project ran 593 files with one failure, the ledger's header totals (fixed: 212 → 210 schemas, 617 → 613 sites), then 27/27. The repo project: 48 files, 849 passed. After the merge: `src/ui`, `src/conversions`, `src/migrations`, error-code ledger, migrate-sentence, alias-integrity and the merge-shape scripts give 120 files, 4299 passed. `check:generated`: 15/15 current. Typecheck green, including `check:scripts-typecheck` and `check:test-typecheck`, which compiles the new test's `@ts-expect-error`. - `@objectstack/metadata-protocol`: `protocol.save-union-issues.test.ts` 25/25. Typecheck green. - `@objectstack/cli`: unit tier 242 files, 3435 passed. The integration tier is declared to CI. - `@objectstack/dogfood`: `zero-set-masking` and `expression-conformance`, 8/8. Typecheck green. - Derived gates: `dispatch-gates --commands` on this head gives 138 families. All 138 ran to exit 0. `--ran` reconciliation: 138 run, 0 NOT-MEASURED, a derived zero with every exit code recorded. Three refusals were cleared by building their prerequisites, not by skipping: `check:skill-examples` and `check:dual-build-cjs-loads` exited 3 until seven packages outside this diff were built. `check:pm-dispatch-gates` and `check:type-check-debt` were re-run without the runner's 480 s cap. - Lint, as a proven narrowing: eslint's own config lints `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` and enables no type-aware linting. The 21 touched code files were all linted (`--format json`): 0 errors, 0 warnings. The repo-wide `pnpm lint` is CI's. - Changeset gates, against BASE: `check:adr-0087-registration` (registered, both ids new here), `check:empty-changeset`, `check-changeset-no-major` and `check:doc-authoring` all pass. The level axis needs this PR's payload, so CI judges it. ## Serial with objectstack-ai#21079 (PR objectstack-ai#21217) At open time PR objectstack-ai#21217 is a draft and not merged, so this PR is first of the pair. This branch deletes both of objectstack-ai#21062's picker pins and removes the picker door case of `zero-set-masking.dogfood.test.ts`, along with the public form and inquiry object only that case booted, since the fixture can no longer carry the retired key. The record-door case is byte-identical. If objectstack-ai#21217 lands first, `main` gets merged here and these deletions are kept. `security-plugin.ts` and `security-service.ts` are untouched. ## Acceptance notes (observed, not filed) - `.changeset/21062-picker-queryable-key.md` is an unreleased changeset describing a change to the route this PR deletes. If both ship in one release, the compiled notes will describe a route that no longer exists. Left for the release compiler; this PR does not edit another PR's changeset. - `objectSchema` from `GET /forms/:slug` still publishes the definitions of declared lookup / `master_detail` / `user` fields. The ruled strip covers the rendered sections only, and widening it would be a new gate. - `ISecurityService.getQueryableFields` loses its only REST reader with the picker (`objectstack-ai#20935`). The method stays: it lives in objectstack-ai#21079's surface. - objectui's parity test carries an exemption reason that describes the retired route. It stays accurate until objectui moves to the spec release that carries this; then its prose is stale (the key stays in the shape). - Two dated audits (`docs/audits/2026-06-*`) and the ADR-0096 table name the route as history and are left as written. ## Deviations - Changeset grade is `minor`, not `major`: the skill and `check-changeset-no-major` refuse `major` in the launch window. BREAKING is carried by the banner, the `Clause-②` line and the ADR-0087 marker. - `LOOKUP_TARGET_MISSING` also leaves the ledger. The card names only `LOOKUP_NOT_PUBLIC`, but the ledger's rule applies to both, and the deleted handler was the only producer of each. - The claim's file surface did not name `view-filter-rule-lowering.ts` (the picker-only helper), `retired-defs/18.ui__FormFieldPublicPicker.ts` (required by the build's manifest-deletion gate), `dropped-refinements.baseline.json`, `engine-double-contract.pinned.json`, `scripts/check-route-envelope.mjs` (ratchet banking), `metadata-protocol` (a stale comment and a test that rode the key), or the platform checklist item. Each follows mechanically from the deletion. - `migrations/registry.ts`: the generated regions come from `gen:migration-registry`. The one hand edit is the `STEP18_RATIONALE` fragment outside the markers, which the retirement skill requires. --- _Generated by [Claude Code](https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21108
Clause-②: no
Moves the console pin past objectstack-ai/objectui#11353, so the vendored Console bundles one zod instance again. Adds a single-zod canary to
scripts/build-console.sh, and keysrelease.yml's Console dist cache on the spec's declared zod range.Target:
31971ff1e28f, the minimal containing commit.objectui-shamovese420df310f5b->31971ff1e28f89cfc45f0c19bc5b05e443f28b79. That is the merge commit of objectui#11353 itself, which closed objectui#11327. objectuimainwas2124d0411175, 2 commits further on, when I chose the target.Why not
main:object-kanban/object-calendarhonourpagenavigation through the host's record navigator) and objectui#11165 (a docs example and its test). Neither is needed by this card.plugin-kanban/src/index.tsxandplugin-calendar/src/index.tsx. Nine asserting pin-citation anchors inpackages/spec/srcread those two files. Taking it would turn this pin's citation re-measure into a navigation-claim change outside this card.docs/releases-maintenance.md) says the target is decided in the objectstack issue. The card's unlock criterion is "a commit that contains objectui#11353". The minimal containing commit meets it and drags in the least.Containment was measured on a private full clone of objectui (
rev-parse --is-shallow-repository= false):gh api …/pulls/11353answersmerged: true,merge_commit_sha: 31971ff1e28f…mainmerge-base --is-ancestor 31971ff1e28f origin/main--is-ancestor e420df310f5b 31971ff1e28f--is-ancestor 31971ff1e28f e420df310f5b--is-ancestor db11afd4967c e420df310f5b--is-ancestor 2124d0411175 31971ff1e28f--is-ancestor e420df310f5b 31971ff1e28fThe range
e420df310f5b..31971ff1e28fis 44 commits.What changed
.objectui-shaand.changeset/console-31971ff1e28f.md. Written byscripts/bump-objectui.sh 31971ff1e28f --no-commitagainst the private clone, so the shared shallow../objectuiwas never deepened. The range has 56 releasing changesets, 12 declared breaking, and 3 commits carry no changeset.adr-0087: TODOplaceholder is answerednot-required (no-migration-prescription). It is worded after the maintainer's ruling on the previous bump (Bump .objectui-sha past objectui f4ed2387e9 (objectui#11163, related-list actions), which also carries objectui#11199: unblocks #20936 and #20299 #20949) and fitted to this diff, and it names all 12 upstream entries. Three of them mirror ObjectStack keys that are already retired and registered here:ui/ChartConfig:aria,ui/Page:assignedProfilesandui/Action:aria. Theobject-form/object-map/object-gantt/object-chartentries move objectui's validator onto thepropertiesbag that the spec rows already declare.showFilterswas never an ObjectStack key onobject-grid. Please confirm this disposition; see the open question in the report on Bump.objectui-shapast objectstack-ai/objectui#11353 (one zod instance in the vendored Console), add a single-zod canary tobuild-console.sh, and key the release console cache on the spec's zod #21108.sdui.manifest.json+scripts/sdui-manifest.record.json. Produced bynode scripts/gen-sdui-manifest-node.mjsover the treebuild-console.shbuilt at the pin: 107 components,@object-ui/core17.6.0. Eleven components changed their published inputs:object-form,object-kanban,object-calendar,object-gantt,object-timeline,object-map,record:quick_actions,element:text,element:definition-list,element:repeaterandaction:button.packages/sdui-parser/objectui-lockstep.json. Re-recorded withpnpm gen:sdui-lockstepagainst the pin's own checkout: 214 grammar lines, 25 codes, containment predicate76c18fb95d1f.check:sdui-lockstepexits 0 with no port owed.scripts/build-console.sh: the single-zod canary. Details in the next section..github/workflows/release.yml: the Console dist key. Details below.packages/spec/src. Re-measured at the new pin (section below). This also regeneratespackages/spec/src/migrations/registry.ts(gen:migration-registry) andcontent/docs/references/ui/view.mdx(check:generated --fix, the one artifact it proved stale), and adds.changeset/objectui-pin-citations-31971ff1e28f.md(@objectstack/specpatch). TheFormField.spandescribe and six migration-entry descriptions name the pin, so the text change publishes.origin/mainmerged twice.19cbfeba7d: no file overlapped this diff, and no generated artifact moved.fbcc05f400, at merge commit8435a830eb, throughscripts/pm/os-regen-merge.sh. There was one textual conflict, in two docblocks ofpackages/spec/src/ui/component.zod.ts. main's finding(spec):action:button/action:iconacceptendpoint, the keyActionSchemarefuses with the prescriptionendpoint→target: one concept, two verdicts in one release #21005 and fix(spec): the object-kanban quickAdd retirement prescribes "delete the key", not the unregistered kanban-ui block #21141 are kept verbatim, and the pin citations were re-applied and re-measured at31971ff1e. No regeneration commit is owed:check:generatedreports 15/15. That merge also moved the spec's zod to^4.6.5; the console still bundles exactly one zod,4.6.5.The single-zod canary
It sits beside the existing bundle canary.
packages/console/dist/assets/*.js. Each zod 4 instance carries one version object (theversionexport ofzod/v4/core/versions), minified as{major:4,minor:6,patch:5}.scripts/build-console.sh --check-single-zod ASSETS_DIR, which builds and writes nothing. That standalone mode is how both directions below were measured.31971ff1e28f(2450assets/*.js)exactly one zod version literal {major:4,minor:6,patch:5} in vendor-objectstack-UrN_Az-7.jsdb11afd4967cdist (the published two-zod split)2 different zod versions—vendor-objectstack-CfLtdaY1.js:{major:4,minor:6,patch:1},vendor-zod-CHLW1iCr.js:{major:4,minor:4,patch:3}{major:4,minor:4,patch:3}{major:4,minor:6,patch:5}chunk2 copies of ONE zod version literalmajor->MAJOR;grep -ocount 1 -> 0 before, 0 -> 1 after)The fixtures are copies in a scratch directory. No tracked file was mutated.
release.yml: the key carries the spec's declared zod range, not the wholepackage.jsonThe card suggested
hashFiles('packages/spec/package.json'). I went with a narrower input:dependencies.zodout ofpackages/spec/package.json. It fails rather than emitting an empty orundefinedrange.CONSOLE_DIST_CACHE_KEYenv and the save step all spell the same key, with-zod-RANGEappended (today-zod-^4.6.5, after the merge offbcc05f400).Why not hash the whole file: its
versionfield moves on every Version Packages merge, and that merge is the push that drives the publish lane. Hashing it would make every publish a cache miss and a cold console rebuild. The comment above the key records that design as deliberate. The declared range is the input the build actually judges: objectui#11353's parity assertion refuses a console zod outside it.release.ymlis not a governed surface.GOVERNED_SURFACESinscripts/pm/check-governed-merges.mjslistsdocs/adr/**,.claude/**,skills/**,AGENTS.md,CLAUDE.mdanddocs/NORTH-STAR.md. No workflow was run or dispatched.Pin-citation re-measure
Each record was handled the same way:
e420df310f5b..31971ff1e28fdiff of each cited file and re-read at the new pin.git hash-object) and corpus counts (git grep -o -Fover the tracked tree, 9912 files) were taken again with the method each record names. The method reproduces everye420df310count exactly at the old pin: 9800 files,objectstack15352,@objectstack/spec6024,timeout1293.check:objectui-pin-citations --verify-anchors, run against the pin's checkout, reports 49 asserting citations matching31971ff1e, and 7 anchor content assertions verified.Records whose CLAIMS moved, not only their numbers. These want a human read:
object-calendarnavigation. objectui#8652 declared the key onObjectCalendarSchema. That removes the oneas anycast objectui#8651 had left: the ledger comment1008-1025is replaced by a declaration note, and the read1026->1018now saysschema.navigation ?? { mode: 'drawer' }.object-kanbannavigation. The read is unchanged. It now compiles through a declaredObjectKanbanSchema.navigationmember rather thanBaseSchema's index signature.object-timeline. objectui#8654 declarednavigation. The read lost its cast (753-756->793-796), and both registrations gained anavigationinput. Theschema.*read set is unchanged, becausenavigationwas already read and is already on this row.action:button. objectui#11168 slice 2 makes thesizeinput publish the five values the row declares. Theinputsrange grew414-551->414-559.record:quick_actions. objectui#10224 rewrote the fail-closed docblock: the bar is replaced by the notice, not hidden. The cited gate lines moved by 6 with their text byte-identical.object-treeoptionality count. By the record's own method,plugin-map/src/index.tsxnow reads 4ElementDataSourceGatehits, not 3. objectui#10859 batch 5 added a comment naming the gate, while the wiring is still three lines. The record now says so.object-timelinedata door's start/end-binding anchor621-622began one line early, on therawData.maphead, ate420df310too. It is re-pointed to657-658and the record says why.No key, default, enum member or export moves.
Acceptance at the new pin
Build.
scripts/build-console.shran at31971ff1e28fwith this tree's client and spec injected, under the shared verify lock:VERDICT command-exit 0.assert-console-spec-injectionpassed: 21 of 21 injected-only descriptions are present and all 8 published-only ones are absent.jsdomrequires Node^22.22.2under engine-strict, and this container's Node is 22.22.0. A verified Node 22.22.2 was prepended toPATHfor the console build only.Boot.
pnpm dev -- --fresh --ui --no-watch -p 41633started the showcase with its own ephemeral DB and the seeded admin.check:console-shareadsobjectui@31971ff1e28f. I drove it in headless Chromium (/opt/pw-browsers/chromium) fromlocalhost:/_console/studio): the dialog renders Display name (name), Package ID (id) and Object namespace (namespace), plus Version, Type and Description.Zod Probe/com.acme.zodprobe/zodprobeand clicked Create package. The request wasPOST /api/v1/packages-> 201./_console/studio/com.acme.zodprobe/data.GET /api/v1/packages/com.acme.zodprobeanswers 200 withwritable: true./_console/apps/showcase_app/metadata/dashboard/showcase_ops_dashboard): the spec-derived form renders Columns, Gap, Refresh Interval Seconds, Header, Show Title, Show Description and Actions. "Spec schema unavailable" appears 0 times.…/metadata/report/showcase_task_overview): Label, Report type, Description and Blocks render, with 0 "Spec schema unavailable".i18n-D40RWXZZ.js, 3 occurrences), so 0 on screen is a reading, not a missing probe.401 GET /api/v1/auth/get-sessionbefore sign-in.404 GET /api/v1/usage/storage, as at the previous pin.501 GET /api/v1/ai/usage, with no AI provider configured.404 GET /api/v1/meta/{dashboard,report}/NAME?state=draft, the no-draft probe of a code-loaded item.--freshtempdir went with the process.Gates and tests, at head
8435a830eb(re-run in full after the merge: 151 derived, 151 run, 0 NOT-MEASURED, 0 UNRUN)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 151 commands, all run sequentially with exit codes captured before any pipe.pnpm check:pm-dispatch-gateshit my 900 s runner timeout under contention (exit 124). Re-run alone with no timeout, it passed: exit 0,dispatch-gates self-test: 1976 cases pass, battery 1096.7 s.dispatch-gates --ranreconciliation:151 derived, 151 run, 0 NOT-MEASURED, 0 UNRUN.check:objectui-pin-citations49 matching,check-sdui-manifestintact at the live pin,check:sdui-lockstepOK,check:console-sha,check:console-injection,check-adr-0087-registration(2 declaring changesets, each with a disposition),check:generated(15 of 15 current),check:docs(227 in sync),check:migration-registry,check:bash32-floor(32 shell files),check:nul-bytes,check-ci-filter-parityandcheck:published-files.pnpm --filter @objectstack/spec test: 591 files, 17368 passed and 1 todo, exit 0.pnpm --filter @objectstack/spec typecheck: exit 0.Acceptance notes
scripts/check-console-injection.mjs's header still says release.yml's key "hashes the pin and the build script". That sentence is now incomplete, because the key also carries the spec's zod range. The file is outside this card's file surface. It is a comment, and nothing reads it.Generated by Claude Code