Repository navigation
feat(spec): give the 45 declared-but-unoffered scalar metadata keys a form row each - #19673
Conversation
Bucket B8 of the top-level zod-only census: keys an object-rooted metadata schema declares, whose liveness verdict is `live` and whose schema node is a scalar, that no form in METADATA_FORM_REGISTRY offered. Re-derived on this tree with the reconciliation gate's own helper block: 49, not the 47 the card relayed. Four are deliberately left out (a deprecated alias, a machine-managed publish gate, a platform-stamped marker, and one key whose describe and its only measured consumer disagree about the value vocabulary). Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
…dles `pnpm i18n:extract` after the 45 new form rows. The three translated locales arrive as extractor fills (the source string verbatim); they are authored in the next commit. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
… three locales
The three translated catalogs are ratcheted: a leaf that reads its `en` source
and is not decided or deferred is red on the day it lands
(`*-echo-decisions.test.ts`). So the 45 new form rows arrive translated rather
than filled. Machine tokens — enum members, `params._selectedIds`, the
`{0000}` / `{recordId}` placeholders, `sys_permission_set.description` — are
kept verbatim in every locale; an author retypes them.
Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
Two decision-ledger counts in platform-objects (the object form's collapsed and open leaf populations, and the catalog-wide translated-label control) and two corpus counts in the lint predicate census. Every one is a measurement of the shipped forms, re-taken here with the delta enumerated rather than inferred: nineteen predicates added, none removed. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
…adata form Judged against `packages/spec/package.json`'s `files[]` rather than by category: `dist` is published and the new help text is in it (measured by grep, with a lit and a dark control), so spec takes a minor. The platform-objects translation bundles reach `dist/metadata-translations` (non-ASCII escaped, which is why a CJK grep over dist reads zero), so that package takes a patch. `@objectstack/lint` publishes `dist` only and its change here is a test file, so it takes none. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 66 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 13 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 136 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 80d28ff44b5e3e4d6422e72c388f1988a8956ff6 && git checkout 80d28ff44b5e3e4d6422e72c388f1988a8956ff6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 97f4f8c8282ebb78de0815cec24eddb8b8335eb0 9e6e78dd1e871028f1969863e87b20ceffd5e0a4 && git checkout -B drift-repro 97f4f8c8282ebb78de0815cec24eddb8b8335eb0 && git merge --no-ff 9e6e78dd1e871028f1969863e87b20ceffd5e0a4
node scripts/docs-audit/affected-docs.mjs --json 97f4f8c8282ebb78de0815cec24eddb8b8335eb0
|
Review catch on the collapsed-section ledger. The card contributed ten leaves — five `advanced` rows, a label and a help text apiece — which is the +10 both counts below already read. Writing 12 closed a sum that was short before this card ever touched the file (9 + 2 + 32 = 43 against an asserted 45), which would have absorbed a two-leaf gap into this card's provenance in the one file whose job is to be the measured record. That gap is named and left standing. Comment only: no assertion value moves, and `git diff` carries no `expect(` line. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
Docs drift — receipted by the seat, with the reading that says no page moves. 2026-09-22T06:29Z
The verdict is that nothing here can falsify a page, and the reason is structural rather than a spot check: this change moves no schema, no accept set and no export. It adds form ROWS to The one hand-written page that names the subject was read in full rather than grepped: ⛔ The 13 release-owned pages are read-only per AGENTS.md Documentation Guardrails and are untouched by this diff.
Generated by Claude Code |
Contract review
Reviewed-by:
⛔ Reviewed against the diff and Clause-② — NO, and the reason is structuralNothing in this diff can change the accept/reject verdict on any input. Measured: the 18 changed files contain zero What does move is the form payload What this seat verified by counting, ⛔ not by reading the report
Three of the card's five example keys do not hold, re-measured here on Copy口径 spot-checked against the schema that owns each key, because a help text stating runtime behaviour is a claim: Two items were sent back before this record, ⛔ neither waved through
Main-branch risk, read rather than assumedThe class this seat caught on a sibling PR — a Filed out of this round, ⛔ by the seat, ⛔ not by the dev#19677 (the object designer offers two formula return types VerdictACCEPT, on this head. CI reads 32 success / 3 skipped / 0 incomplete, latest-per-name. Not a governed surface, so no maintainer merge is owed; the two confirmations the 速读 asks for are the author's call at review, ⛔ not a gate on landing. Generated by Claude Code |
The published bullet said the liveness row's evidence pointer carries no read of the key. That is false and the method behind it was wrong: the read is transitive, so grepping the cited file for a literal `.format` could not see it. `resolveAutonumberFormat` falls back from `autonumberFormat` to `format`, and the engine calls that resolver for every autonumber field — as does the SQL driver. The decision does not move: the key still gets no control, and for a stronger reason. One `z.string()` carries three vocabularies — an autonumber pattern on the engine arm, a date display style on the renderer arm, and a third in the published `describe` that nothing measured honours. No help text can be written until one of them is ruled. Re-verified on this tree, and the renderer arm at the sha this repo pins rather than at whatever the sibling checkout happens to be. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
… metadata form (objectstack-ai#20064) Part of objectstack-ai#19333 Clause-②: no This PR gives the top-level keys that no metadata form may offer a recorded reason. The reasons live in the metadata-form reconciliation ledger, at the root coordinate PR objectstack-ai#19639 added. It does **not** switch on the top-level `zodOnly` assertion. After this PR, 40 top-level keys on the 16 object-rooted types still have neither a form row nor a recorded reason, so the population does not close. What stays open under objectstack-ai#19333: the one residue key that fits none of the card's buckets (`field.format`), and the open questions on the seven `view` keys folded in from objectstack-ai#19334, which is no longer open. The other 39 residue keys are the structured-control bucket, and they are carded on objectstack-ai#19332. One file changes: `packages/spec/src/system/metadata-form-zod-reconciliation.test.ts`. It gets 14 ledger rows and a comment block. No schema, form, `describe()`, liveness row or generated artifact changes. ## The population, re-derived on this tree ⛔ No number is carried over from the card or the thread. I copied the reconciliation gate's own helper block byte for byte, from the top of the file down to the first `describe(`, into a throwaway probe test next to it. The probe runs the same `resolveCoordinate(form, root, ROOT_PATH)` / `offerableKeysAt` / `omittedAt(LEDGER, …)` calls the gate runs. It was deleted afterwards and is not in the diff. Final slice: bytes 0..34234, sha256 `7b97432d8408…`, prefix verified byte-identical on disk. The controls are asserted inside the probe: | control | reading | |:--|:--| | LIT: `name` | offered by **17 of 17** forms | | DARK: a fabricated key, form side | offered by **0** forms | | DARK: the same key, schema side | declared by **0** schemas | | stage (17 registered forms) | before (base `7e6ca1787a`) | after (head `39590d226c`, merged with main `980bc05e5b`) | |:--|--:|--:| | top-level zod-only keys, overlay included | 229 | 229 | | of those, the ADR-0010 overlay (skipped at the root since PR objectstack-ai#19639) | 132 | 132 | | non-overlay keys with no offer and no recorded reason | **97** | **83** | **Arithmetic.** 229 = 274 − 45. The census round measured 274 at `596090efbe7`, and PR objectstack-ai#19673 has since landed 45 scalar form rows. 97 = 229 − 132, which is also 142 − 45. 83 = 97 − 14, and 14 is the number of rows this PR adds. **The card's 145 and the thread's 142 count two different sets.** Neither is a misreading. - 145 = 132 overlay + 13 keys in the card's own three other sub-buckets (4 + 4 + 5). The card's table adds up to it. - 142 = 274 − 132, every non-overlay key, including the 47 scalar, 39 structured and 36 + 7 `view` keys that belong to other cards. - On this tree the matching figures are **146** (132 + 14; the extra key is `field.system`, see below) and **97**. ## The 229, by sub-bucket, measured I assigned each bucket from the key's own `describe()` and its row in `packages/spec/liveness/TYPE.json`. None was decided here: | sub-bucket | criterion | keys | disposition | |:--|:--|--:|:--| | ADR-0010 provenance / lock overlay | in `FRAMEWORK_FIELDS` (the 7 `MetadataProtectionFields` keys on all 17 forms, 119, plus `protection` on 13) | 132 | **one reason, already in place**: the `FRAMEWORK_FIELDS` skip. No rows, and a root row naming an overlay key is refused by the resolve test | | platform-written, never authored | describe says not authored / never authored / machine-managed / auto-injected | 5 | a root `omit` row each | | deprecated or legacy alias | describe carries `[DEPRECATED …]` or `[LEGACY ALIAS …]` | 4 | a root `omit` row each, the shape of the `page.interfaceConfig.sourceView` precedent | | declared, not enforced yet | liveness verdict `planned` / `experimental`, or every child of the row is | 5 | a root `omit` row each, saying the key is out of this gate until enforced | | the seven `view` keys from objectstack-ai#19334 | no liveness verdict at any coordinate | 7 | measured, **no row** (see below) | | residue, object-rooted | fits no bucket above | 40 | 39 structured (objectstack-ai#19332) + `field.format` | | residue, `view` | per-arm keys | 36 | outside the top-level direction until the per-arm forms exist, per the objectstack-ai#19330 ruling (letter A) | 132 + 5 + 4 + 5 + 7 + 40 + 36 = **229**. ### The 14 rows - **Platform-written:** `app._unpublished`, `field.system`, `view.columnState`, `view.isPinned`, `view.sortOrder`. - **Deprecated / legacy alias:** `object.displayNameField`, `object.titleFormat`, `view.drawerWidth`, `view.groups`. - **Not enforced yet:** `object.externalSharingModel` (planned), `field.useGrouping` (planned), `page.requires` (planned), `agent.structuredOutput` (experimental), `action.onSuccess` (both children `navigate` and `openIn` planned). Why the five `view` rows are safe while `view` is outside the direction: each of these reasons holds on every arm. None of the rows can excuse a key that a future per-arm form ought to offer. `field.system` is the one key not in the card's 145. PR objectstack-ai#19673 held it out of the scalar bucket, and the census round routed it here. It meets the platform-written criterion on its own `describe()` (`Auto-injected system/audit field`, set against `author-declared business fields`). Every writer is platform code: `packages/spec/src/data/injected-system-column-provenance.ts`, `packages/objectql/src/search-companion.ts`, `packages/metadata-core/src/audit-field-governance.ts`. The record validator skips its required and multi-value checks for a flagged column (`packages/objectql/src/validation/record-validator.ts`), so a control would let an author turn those checks off by claiming a false provenance. ### `app._unpublished`: what it is It is the ADR-0045 §3 publish gate, amended to its own key. The AI materialization path writes it, `POST /packages/:id/publish-drafts` clears it, and `filterAppForUser` reads it. It shares the ADR-0010 envelope's `_` naming convention but is **not** a member of that envelope: it is not in `MetadataProtectionFields`, and only `AppSchema` declares it. So its absence from `FRAMEWORK_FIELDS` is correct, not a gap, and nothing here widens that set. It now has its own root row, which gives a machine a place to read its machine-written status. ## The seven `view` keys **The planned mechanism does not work.** The plan was to give them verdicts in `packages/spec/liveness/view.json`. That ledger's walk stops at the `container` arm of the `view` union: the gate's `shapeOf` takes the first OBJECT member, and the `viewItem` arm is a discriminated union, so it is passed over. `--dump view` walks only name, label, object, list, form, listViews, formViews and the overlay. A row for any of the seven is therefore an ORPHAN. Measured by planting a `config` row: `check:liveness` exited 1 with `✗ 1 ORPHAN ledger row(s) … view/config`. The file was then restored, blob `21c15486454c` equal to HEAD. **What was measured instead.** Readings at framework `7e6ca1787a` and objectui `62597c588`, re-read at framework `980bc05e5b` and objectui `f8a9d0fb0596` (the console pin on that main), with the same results: | key | writers | readers | reading | |:--|:--|:--|:--| | `config` | `defineViewItem`; the console's `viewEnvelope` (Save as view); `expandViewContainer` | `MetadataManager.getViewsByObject` serves it; the console's View editor edits `draft.config` | **authored**, live | | `viewKind` | `defineViewItem`; `viewEnvelope`; `expandViewContainer`, the server's `viewIdentityPatch` and the console's `buildPersistedViewBody` also stamp it | `getViewsByObject` filters on it; the console's `listViews` drops the form family on it | **authored** discriminator, live | | `order` | `expandViewContainer`; the authoring door's own guidance names it "the authored default" beside the per-user `sortOrder` | `getViewsByObject` sorts on it | **authored**, live | | `isDefault` | declared on the strict authoring door; the console's set-default (`setDefaultViewPatches`) | the console's switcher | **authored**, and also console-written as shared state | | `scope` | `expandViewContainer` stamps `package`; nothing writes `shared` or `personal` | the generic metadata list's `scope` filter | platform-stamped; no runtime writer | | `owner` | none in either repo | none in either repo | inert | | `hidden` | none in either repo | none in either repo | inert | None of the seven gets a row. The four authored keys would be excused by a row, and whether an arm form should offer them is a question for the first per-arm form, not for this ledger. The other three have no live writer, so there is no platform-written state to give as the reason. The readings are kept as a comment at the end of the ledger. They are not in `view.json`, because `liveness/` is in `@objectstack/spec`'s published `files[]` and a note there would change the tarball. ## Why the `zodOnly` check is not wired The direction objectstack-ai#19188 needs covers the 16 object-rooted types (per the objectstack-ai#19330 ruling, letter A). 40 of their keys still carry neither an offer nor a reason. Wiring the check now would turn those 40 into red lines, which is the shape the census round refused. `view` stays outside the direction until its first arm form is registered. ## Ablation Every leg went through `scripts/ablation-replace.mjs`. The anchor had to hit, the mutation was verified on disk by marker count and blob change, and the restore was proven by blob hash plus an empty `git diff HEAD`. Every leg restored to `efc4637425b6` (the file at `9c63b38770`). The subject is imported from `src` by relative path, so no build or `dist/` sits between the mutation and the run. 1. **Remove the overlay reason.** `offerableKeysAt`'s root filter became `return keys;`. The gate went red: 2 failed of 54, and `_lock is overlay and must not be offerable at the root` names the keys. The census went 83 → **215**, exactly +132, all overlay keys. 2. **Delete one recorded reason** (the `app._unpublished` row). The census went 83 → **84** and names `app._unpublished`. The gate stayed **green**, 54 of 54. That is the measured statement of what "unwired" means: while the `zodOnly` direction has no assertion, no gate notices a row going missing. 3. **Point a row at a key the form offers** (`displayNameField` → `nameField`). The resolve test went red: `object.(root).nameField: the form offers it now — drop the ledger entry`. The new rows cannot outlive the omission they excuse. An earlier attempt at the `view.json` probe was a no-op: the replacement contained its own anchor, so the tool refused before running anything. It was re-run with a non-self-matching anchor, and that run is the reading quoted above. ## Verification (head `39590d226c`) - `@objectstack/spec` build: exit 0. Tree clean afterwards (no `authorable-surface.base.json` or other artifact movement). - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2`: **534 files, 15708 passed, 2 todo**. Pre-merge at `9c63b38770`: 533 files, 15681 passed. - `pnpm --filter @objectstack/spec typecheck` (`tsc --noEmit` + `check:scripts-typecheck` + `check:test-typecheck`): exit 0. The test layer holds its identity-pinned debt with no new signature. - The reconciliation gate plus the probe: 2 files, 54 tests passed. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 77 commands, each run with its exit code captured before any pipe. `--ran` first reported `77 derived, 73 run, 4 NOT-MEASURED, 0 UNRUN`: the four exit-3 families `check:doc-formula-expressions`, `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt` were `PREREQUISITE NOT MET` on other packages' `dist/` while the whole-closure build waited for the shared lock. After that build ran under the lock (turbo 72/72, verdict 0), all four were re-run and exit 0, and `--ran` reports `77 derived famil(ies) accounted for — 77 run, 0 NOT-MEASURED`. (Updated by the seat from the dev's report `5825062779`.) - `check:liveness`: green, and `state-counts.md is current`. - Lint, narrowed and measured: `eslint --no-inline-config --format json` on the one changed file gives 1 file, 0 errors, 0 warnings. The population comes from eslint's own `--print-config` (the file is linted, not ignored). The config has no `parserOptions.project`, no `projectService` and 0 typed rules. It is therefore not type-aware, and a test-file edit cannot move any other file's verdict. ## Changeset None. The only changed path is not published: `npm pack --dry-run` of `@objectstack/spec` lists 2034 files, with 0 `*.test.ts` among them, while the positive controls `src/ui/view.zod.ts` and `liveness/view.json` are present. The documented no-release route is the `skip-changeset` label (AGENTS.md, Post-Task Checklist step 3). This PR's author does not write labels, so the seat applies it. ## Acceptance notes - **The five not-enforced rows hold only while the verdict does.** No leg re-reads the liveness verdict, so when one of these keys becomes enforced its row goes stale silently. The ledger comment says to delete the row at that point. Carrier: whichever PR enforces each key. - **`object.actions` sits on the edge of the platform-written criterion.** Its describe says "auto-populated from top-level actions via objectName", but 8+ platform objects author `actions: […]` inline, so it stays with the structured bucket (objectstack-ai#19332). - **The liveness ledger covers only one arm of the `view` union** (`container`). The keys of the other arms can hold no verdict there at all. This was noted by the census round, and it goes to whoever registers the first per-arm form. The two inert keys above are the part of it that is a finding, handed to the filing seat. - The `274 … 132 of them this overlay` figures in the file's existing comments are dated readings from the census tree and were left as written. --- _Generated by [Claude Code](https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…meField (objectstack-ai#21854) Fixes objectstack-ai#21765 Clause-②: no Item 3 of the card, under the director seat's ruling A (`5989738766`): Studio's object form now offers `imageField`, the record's picture, as a plain `text` row beside `nameField`. Items 1 and 2 landed in PR objectstack-ai#21824. With this PR, the card's last open item is done. ## What changed - **The form row** (`packages/spec/src/data/object.form.ts`): one `{ field: 'imageField', type: 'text', colSpan: 1, helpText }` row directly after `nameField`. It has the same face as `nameField`, for the same reason: the value names one of the object's own fields, and the registry has no own-field picker. The row adds no picker and no validator. `refuseNonPictureImageField` at parse stays the one judge, and its refusal at save is what an author sees. The helpText says what the parse accepts: a field of this object whose type is `image` or `avatar`. Left empty, there is no record picture and no placeholder. The metadata form's text input writes no key when cleared (objectui `SchemaForm.tsx` at the pin `0abd4f9f8769`: `onChange(e.target.value || undefined)`), so "empty" in the helpText means the key is unset, which the parse reads as no picture. - **The stale ledger row** (`metadata-form-zod-reconciliation.test.ts`): the `imageField` `omit` row in the declared-not-enforced group is deleted. No other row moves. - **The catalogs**: the four `packages/platform-objects/src/apps/translations/*.metadata-forms.generated.ts` files were regenerated with `node scripts/check-i18n-bundles.mjs --write`. The zh-CN / ja-JP / es-ES `label` and `helpText` leaves were then written in place, since translated-locale values are hand-written in those files (AGENTS.md, Documentation Guardrails). A second `--write` kept them and dropped the six provenance entries the first extract had added to the three `*.source-hashes.generated.ts` companions. Those companions are byte-unchanged in this diff. - **The pin** (`metadata-form-declared-rows.pin.test.ts`): three tests. A lit and dark control for the locator. The key is offered once, as a `text` row with no widget. It sits directly after `nameField`, in the same section, at the same `colSpan`. The reconciliation test proves only that the key is offered somewhere on the form; it reads neither the control nor the position, so this pin covers what it misses. No test title or string carries a tracker id. - **Two measured counts in `platform-objects` were re-taken**, both forced by the new row: - the object form's open-section leaf population: 114 → 116 (`object-collapsed-sections-echo-decisions.test.ts`); - the catalog-wide translated-label control: 659 → 660 per locale (`object-lifecycle-panel-echo-decisions.test.ts`). The `openEchoes` reading stays at zero. - **Changeset**: `@objectstack/spec` patch and `@objectstack/platform-objects` patch (below). ⛔ No Zod change, no liveness change, no new gate or ledger class. ## The reconciliation test: predicted, then measured Predicted before writing: red without the form row, green with it. Measured on the committed state, with `scripts/ablation-replace.mjs` in wrap mode and a `trap` restore proven by blob equal to HEAD: | state | reading | |---|---| | row deleted, form row present (this PR, `c730a8c598`) | 2 files, **87 passed (87)** | | row deleted, form row removed (anchor 1 → 0, blob `d867e358e5e5` → `a67cdaed2b22`) | **3 failed / 84 passed**: `object.(root): accepted by the Zod but unauthorable in the form … expected [ 'imageField' ] to deeply equal []`, plus the two new pin tests. Restored: blob `d867e358e5e5` == HEAD, `git diff HEAD` empty | | control: the stale row planted back beside the form row | **1 failed / 75 passed**: `object.(root).imageField: the form offers it now — drop the ledger entry`. Restored: blob `2a7c49dca958` == HEAD | The first attempt at the control leg was a no-op. The tool refused it before running anything, because the replacement text contained its own anchor. It was redone with a non-overlapping replacement, which is the reading above. ## What ships Each package was built and its `dist/` grepped, with a positive control: - `@objectstack/spec`: the new helpText is in 8 dist files; the control, `nameField`'s helpText, is in 8. - `@objectstack/platform-objects`: the new en helpText is in 6 dist files, the control in 6. The zh-CN, ja-JP and es-ES labels are in 6 each, matched on literal or unicode-escaped text. Both packages publish the change, so both get `patch`. `skip-changeset` does not apply. ## Verification (final head `9f6b177875`) - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2`: Test Files 614 passed | 1 skipped (615), Tests 18385 passed | 1 skipped | 1 todo. The skipped file is `scripts/root-entry-type-nameability.pin.test.ts`, which is gated by `OS_EXPECT_ROOT_NAMEABILITY`. - `pnpm --filter @objectstack/spec run typecheck`: exit 0. `tsc -p tsconfig.test.json --listFilesOnly` compiles `object.form.ts` and both changed spec tests. - `pnpm --filter @objectstack/platform-objects exec vitest run --maxWorkers=2`: 59 files, **949 passed**. Run before the two counts were re-taken, it gave exactly two failures: `expected 116 to be 114` and `zh-CN positive control: expected 660 to be 659`. Typecheck: exit 0. - `metadata-forms-vocabulary.test.ts`: 5 passed. `pnpm check:i18n`: exit 0, `platform-objects in sync (11 bundle(s))`. `pnpm check:i18n-coverage`: exit 0, `13 config(s), 621 baselined untranslated string(s), none new`. - `pnpm --filter @objectstack/spec run check:generated`: exit 0 (15 artifacts). - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 87 commands from 10 paths against the merge base `8832655af`. All 87 were run on `9f6b177875`. `--ran` reconciliation: `87 derived famil(ies) accounted for — 87 run, 0 NOT-MEASURED`. Four spec gates first exited 3 (`PREREQUISITE NOT MET`: the dist predated a test-title edit). They exited 0 after `pnpm --filter @objectstack/spec build`, and the record keeps that last reading. - eslint `--no-inline-config --format json` on the 9 changed TS files: 9 files, 0 errors, 0 warnings. The population is `eslint.config.mjs:971` (`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`). Linting is not type-aware (no `parserOptions.project`), so untouched files' verdicts cannot move. ## Acceptance notes - **Surface, stated.** The claim named `object.form.ts`, the reconciliation test, the four catalogs and a changeset. Three more files changed: - `metadata-form-declared-rows.pin.test.ts` holds the pin the dispatch asked for (item 4). - The two `platform-objects` echo-decision tests carry the counts the row moves. Those count lines are the ones PR objectstack-ai#19673 moved when it added rows to the same section. A reviewer who reads them as outside the surface can say so. - No `platform-objects` source or object file moved, and no other catalog key changed. - The authored leaves live in the generated catalogs themselves, so no further file joins the surface for them. - `origin/main` gained one commit after the branch point (`2799155678`, test titles in `packages/spec/src/data/`). It touches none of this PR's paths. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #19331
Clause-②: no
Bucket B8 of the #19188 census: keys an object-rooted metadata schema declares, graded
liveby the liveness ledger, whose schema node is a scalar, and that no form inMETADATA_FORM_REGISTRYoffered. Each gets exactly one form row. 45 rows landed; 4 keys are deliberately still unoffered and named below.The population was re-derived, and it is 49 — not 47
⛔ No number was inherited. The reconciliation gate's own helper block was sliced verbatim —
packages/spec/src/system/metadata-form-zod-reconciliation.test.tslines 104-469, sha25625ef6c218394d1ea…— into a throwaway probe beside it, run throughvitestagainst the live registry, and deleted again. It is not in the diff.Controls asserted inside the probe, not beside it:
nameThe query, and what it read — for each of the 17 registered types:
offerableKeysAt(getMetadataTypeSchema(type), ROOT_PATH)minustopLevelFields(METADATA_FORM_REGISTRY[type]), then filtered on three predicates in turn — root node isobject(not a union), livenessstatus === 'live'read frompackages/spec/liveness/TYPE.json, and the property node peeled of wrappers ONLY (never ofarray/record, which would make a list read scalar) resolving tostring/number/boolean/enum.viewis union-rooted and contributes the other 48)live142 is the card's 274 with the overlay taken off (274 − 132 = 142), so the census's own arithmetic reproduces; 49 is not 47, and that is the finding the re-derivation was asked for. ⛔ No
viewkey is in the population, as the card required.Three of the five examples the card names are not in it, which is the same drift measured from the other side:
object.nameField✅ andobject.ownership✅ — both landed here.field.precision— already offered (field.form.ts), so it is not zod-only today.action.shortcutKey— no such key anywhere in the repository. The nearest thing isaction.shortcut, removed with its form input by the 2026-07POST /data/sharing/rules绕过SharingRuleSchema:criteria缺失或拼错静默变成"共享该对象全部记录",与 ADR-0049 "never seeded as a permissive match-all" 直接冲突 #3896 audit close-out in@objectstack/spec17.0.0 — the tombstone ataction.zod.ts:1424-1438cites no ADR. ADR-0049 is the doctrine;POST /data/sharing/rules绕过SharingRuleSchema:criteria缺失或拼错静默变成"共享该对象全部记录",与 ADR-0049 "never seeded as a permissive match-all" 直接冲突 #3896 is the act.permission.license— not declared byPermissionSetSchemaand not a row inpackages/spec/liveness/permission.json.The four keys deliberately left out
Each would be a trap rather than an offer. All four are recorded reasons, which is sibling card #19333's bucket — ⛔ nothing here writes a ledger row for them.
object.displayNameField[DEPRECATED → nameField]. Its canonical replacement lands here; offering the alias beside it teaches the retired spelling.app._unpublishedNever authored— a machine-managed publish gate written by AI materialization and cleared by publish-drafts.field.systemapplySystemFields, the search companion). It is read on the write path — the record validator skips required and multi-value checks for a flagged column — so a control lets an author assert a false provenance that silently disables validation for that field.field.formatz.string()key carrying THREE value vocabularies. The engine reads it as an autonumber pattern (resolveAutonumberFormat,autonumber-format.ts:196-202, called fromengine.ts:5043-5051); objectui reads it as a date display style,short/relative; the publisheddescribenames a third,Format string (e.g. email, phone), that nothing measured honours. No help text can be written for a key whose vocabulary is not yet ruled.The judgement in each row
The control follows the scalar type; the copy states what the runtime enforces, including what ABSENCE resolves to — the half an author cannot read off an enum.
object.sharingModelsays a custom object that omits it resolves toprivate;field.stepsays the write path does not reject a value off the step grid;action.undoablesays an action with nooperationhas no write set to capture;action.requiresFeaturesays the key is lowered intovisibleat parse time and stripped, so no downstream consumer sees it.Nineteen rows carry a
visibleWhenMEANINGFULNESS gate — not a parse gate.FieldSchemaaccepts every one of these keys whatever the sibling value is, but only some field types, page kinds or action operations ever read them, and offering a knob the runtime does not deliver is what Prime Directive #10 forbids. Fifteen are mirrored from the same key's row in the object designer's quick-add grid, so the two authoring surfaces cannot disagree about when a knob applies.Three enums carry no inline
optionslist, and that is a constraint rather than a preference.FormSelectOptionSchema.valueis a system identifier (^[a-z][a-z0-9_.]*$), soobject.managedBy's four hyphenated members,action.openIn'snew-tabandaction.execution'sperRecordcannot be spelled as option values at all —defineFormthrewinvalid_formatat module load when they were. Those three derive their enum from the served JSON Schema, which carries every member verbatim, and the meanings ride the help text. See Acceptance notes.The i18n half — an EXPANSION of the declared file surface, stated here
The claim declared
packages/spec/src/**/*.form.ts. Six further files changed, and the expansion is mechanically forced:os i18n extractwalksMETADATA_FORM_REGISTRY, so a new row moves the generated catalogs andpnpm check:i18nreds without them.metadata-forms.generated.tsbundles, regenerated withpnpm i18n:extract.zh-CN,ja-JPandes-ES, not left as extractor fills. Those three catalogs are ratcheted: a leaf that reads itsensource and is neither decided nor deferred is red on the day it lands. Verified from the provenance side too — after re-extracting, 0 of the 90 keys remains in any locale'ssource-hashes.generated.ts, which is the table that holds an entry exactly while a leaf is still a byte copy of its source.params._selectedIds, the{0000}and{recordId}placeholders,sys_permission_set.description.advanced36 → 46), its open-section population (94 → 96), and the catalog-wide translated-label control (538 → 583, which is +45 in every locale — exactly the rows that landed).packages/lint: the shipped-form predicate census (53 → 72) and its==/!=literal half (41 → 52). Measured, not inferred from the delta — the*.form.tscorpus was differenced against the merge base5ce3705052by form/field/source: 19 entries added, 0 removed, and the eight that do not reach the second census are sevenin-list gates plus one comparison against a baretrue.Verification
Derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon a tree atorigin/main(the stale-tree warning was cleared by merging8f30c188afirst), every command run with its exit code captured before any pipe, then reconciled:Two of the 85 exited 3 on first pass —
check:dual-build-cjs-loadsandcheck:type-check-debt, bothPREREQUISITE NOT METwith nodist— which measures nothing and is neither a pass nor a finding. Both were re-run after the prerequisite build and both are green; the recorded codes are those readings.Suites, all at
3e453d8da:@objectstack/spec@objectstack/platform-objects@objectstack/lint@objectstack/objectql(consumer —getMetaTypesserves this registry)@objectstack/rest(consumer — the/meta/typespayload)@objectstack/cliunit tier (consumer — the i18n extractor walks this registry)turbo run typecheckover spec + platform-objects + lint: 10/10 tasks green.pnpm --filter @objectstack/spec run check:generated: all 15 generated artifacts up to date, working tree clean afterwards. Repo-widepnpm lint(eslint . --no-inline-config) run whole, not narrowed: exit 0.The reconciliation gate itself and
metadata-form-declared-rows.pin.test.tsare green throughout, and thefield.relatedListFilter/object.validationsrows they pin are untouched.What this deliberately does NOT land
⛔ The gate that would notice a missing row. The top-level
zodOnlydirection stays unwired. Turning it on today would turn the remaining absences into red lines with no offers behind them, which is the shape the census round refused in as many words. This card lands offers; the assertion is a separate card, and landing it without the rows is precisely what was refused.Acceptance notes
Findings met on the way, ⛔ none fixed here, ⛔ none filed by this seat:
object.form.tsoffers two formula return types the schema refuses. The quick-add grid'sfields.returnTyperow liststext,number,boolean,date,datetime,currency;FieldSchema.returnTypeisz.enum(['number','text','boolean','date']). Authoringdatetimeorcurrencyfrom the object designer writes a value the parse rejects. Thefield.form.tsrow added here lists the four declared members only.Seam: spec:FieldSchema.returnType → renderer:object.form.ts fields.returnType. Dedupe words:returnType option datetime currency·form option refused enum member·quick-add grid returnType drift.The form face cannot express an enum whose members carry a hyphen or a capital.
FormSelectOptionSchema.valueis a system identifier (^[a-z][a-z0-9_.]*$), sosystem-data,engine-owned,append-only,better-auth,new-tabandperRecordare unspellable as option values — measured, not inferred:defineFormthrewinvalid_formatonoptions[2].valueat module load in this worktree. The workaround used here (derive the enum, put the meanings in help text) works, but a form author who does not know the bound writes an options list that fails at import.Seam: spec:FormSelectOptionSchema.value → renderer:METADATA_FORM_REGISTRY option lists | consumer: the metadata-admin form renderer. Dedupe words:FormSelectOptionSchema value system identifier·option value hyphen refused·enum member unspellable form option.field.formatis one key with three value vocabularies, and itsdescribenames the one with no consumer. The ledger row is{"status":"live","evidence":"packages/objectql/src/engine.ts"}and that evidence carries:engine.ts:5043-5051resolves everytype: 'autonumber'field throughresolveAutonumberFormat(packages/spec/src/data/autonumber-format.ts:196-202), which readsfield.formatas the shorthand for the autonumber pattern whenautonumberFormatis absent. A second consumer reads the same key differently: objectui's date/datetime cell honoursshort/relative(packages/core/src/utils/dataset-format.ts, pinned bydatetimeCell.formatVocabulary-8853.test.tsxandDetailSection.dueLikeReachesTheCell-9729.test.tsx). The declaredFormat string (e.g. email, phone)is honoured by neither, so an author who follows the describe on an autonumber field renders the literalemailas their pattern. This is why the key gets no row here. Dedupe words:field.format three vocabularies·autonumber format shorthand·describe names a vocabulary no consumer honours.page.form.ts's existingkindrow saysfull or slottedwhile the derived enum offers five members (full,slotted,html,react,jsx) — an under-description of an existing row, noted only. No card: an omitted member is not a reproducible defect.object.tenancy,app.contextSelectorsandaction.onSuccesscarry a liveness row with nostatusfield at all. None is a scalar, so none is in this bucket; noted because the probe had to classify them and a status-less row resolves to no verdict.Items 1-3 are handed to the filing seat with their dedupe words; ⛔ this seat opened no issue.
field.system,app._unpublishedandobject.displayNameFieldare ⛔ not new cards — they are inside sibling #19333's scope (recorded reasons) and are named there.维护者速读(草稿)
改了什么。 45 个元数据字段原本在 schema 里已声明、运行时也确实在读,却没有任何表单控件,作者只能去 Source 标签页手写 JSON。这次给每个字段补上一行表单控件,并把配套的四语文案一次写全。
为什么改。 手写 JSON 没有任何校验提示:写错一个相邻键名,要等运行时拒收才知道。补上控件等于把这批能力从"藏着"变成"能点",也让 AI 代写元数据时读到的是准确的字段说明,而不是自己猜。
风险与代价(含回滚)。 不动任何 schema 的接受集合,写进去的数据形状一个字节都没变,因此风险面只在"界面上多了几行"。有 4 个键故意没做控件:两个是平台自己盖章、作者不该碰的,一个是已废弃的旧别名,还有一个的官方说明和唯一能查证的消费方互相矛盾——与其猜一个说法,不如先不给入口。回滚就是回退本 PR,界面回到今天的样子,没有数据迁移。
席位意见。 本席(
domain:spec执行席位)已逐条复核,⛔ 不是按开发的报告核的,是按 diff 和origin/main核的:*.form.ts里按field:键计数:1+1+2+19+6+1+4+9+1+1 = 45。label:与 45 条helpText:,+180 行 = 45×4,四个语言包完全一致;source-hashes.generated.ts按声明不在 diff 里。*.form.ts+ 1 个 changeset + 7 个被机械强制的(四个语言包、两个 platform-objects 铉子、一个 lint 铉子)。field.precision今天已经有控件(field.form.ts:73);shortcutKey全仓 0 处;permission.license既不在permission.zod.ts,也不在liveness/permission.json。ownership、sharingModel、undoable、app.hidden、nameField五处,逐条对回声明它的.describe(),一致;其中sharingModel的「缺省 = private」带了「自定义对象」这个限定词,是对的 —— 系统对象缺省解为 public。object-collapsed-sections-echo-decisions.test.ts的注释把 2 条文案错记在本卡名下(本卡实际带来 10 条,不是 12 条;那 2 条的缺口在本卡之前就已存在)。已退回修正,断言值一个没动。field.format一行与验收笔记第 3 条,原先写「台账里引的证据文件根本没读这个键」—— 本席实测下这句是错的:引擎确实读它(当作 autonumber 的格式简写)。不给控件的结论不变,而且更站得住:同一个键今天背着三套取值词汇,而官方说明写的那一套没有任何消费方。changeset 里的同一句话正由开发改(它会发到 npm)。对你要确认的两点,本席的建议是两点都可以收:① 不做控件的四个键里,
field.system那一个尤其值得保留 —— 给它控件等于让作者自称“这是系统列”,而记录校验会对系统列跳过必填与多值检查,等于多一个静默关掉校验的开关;② 文案口径写“运行时到底怎么做”、并且把“缺省会解成什么”写进去,是作者从枚举值上读不到的那一半,值得定为默认风格。席位复核写于 2026-09-22T06:39Z;此前的文档漂移提醒已单独回执。
你要做的。 确认两点:① 4 个不做控件的键,理由是否认可;② 这批控件的文案口径(写"运行时到底会怎么做",而不是复述字段名)是否就是你要的默认风格。确认后按常规流程合入即可。
Generated by Claude Code