Skip to content

test(spec): record why field.format is never offered by a metadata form - #20322

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-19333-field-format-recorded-reason
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-19333-field-format-recorded-reason

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #19333
Clause-②: no

What this changes

One root omit row in the metadata-form reconciliation LEDGER (packages/spec/src/system/metadata-form-zod-reconciliation.test.ts), for field.format, under a new fifth reason group, renderer-owned vocabulary. The group's head comment states what admits a key to it. All three conditions must hold:

  1. the key's describe() says the spec declares no vocabulary for its value;
  2. every server-side meaning it has is superseded by another key the form already offers;
  3. every other word it carries is a display hint, and the renderers that read it declare its vocabulary.

The root-coordinate intro comment names this fifth group next to the other four: platform-written, deprecated or legacy alias, declared but not enforced, and the ADR-0010 overlay, which has no rows. The existing three reason classes could not carry the key. Its liveness verdict is live, its describe carries no DEPRECATED marker, and authors write it.

This is test-only. There is no schema, form, describe(), liveness row or generated artefact change. The top-level zodOnly assertion stays unwired. That wiring is item 2 of #19333, which waits on the 39 structured keys of #19332.

This PR is item 1 of the remainder recorded on #19333 (landing note 5825783183). It takes the seat's scope call, option A (claim 5859116056), which answers the open question in the predecessor report 5825062779.

The reason, measured on this tree (base 2aa25efb4)

  • (a) The spec declares no vocabulary. FieldSchema.format is a bare z.string().optional(): node type string, 0 checks, read through the gate's own subSchemaOf and unwrap. Its describe opens with "Free-form string whose meaning depends on the field type and on the reader. The spec declares NO vocabulary for it and checks nothing but that it is a string".
  • (b) The one server-side meaning is superseded, and both forms offer the successor.
    • The only server-side reader is resolveAutonumberFormat (packages/spec/src/data/autonumber-format.ts). It is called by the query engine, the SQL driver and the autonumber lint. It returns autonumberFormat first, and falls back to the format shorthand only when that key is absent. Probe readings: { autonumberFormat: 'A-{0000}', format: 'B-{0000}' } resolves to A-{0000}, and { format: 'B-{0000}' } resolves to B-{0000}.
    • A grep of packages/*/src outside spec finds no other read of a field definition's .format. The hits are CLI flags, loader stats, serializers and the validation rule's own format key.
    • field.form.ts offers autonumberFormat at its root (visibleWhen autonumber). The object designer's fields quick-add grid (object.form.ts) offers it as well. Neither offers format. Both readings go through the gate's topLevelFields and nestedLists.
  • (c) The display words belong to the renderers. objectui at the pinned .objectui-sha f8a9d0fb0596 shows this. In packages/fields/src/index.tsx, @object-ui/fields declares FORMAT_TO_RENDERER (phone, tel, telephone, email, url, uri, link, currency, money, percent, percentage), applied only to textual base types. The same file has the date and datetime cells' own style words (compact, relative, short). plugin-report (formatValue.ts) and plugin-dashboard (recordFields.tsx) read the key with rules of their own.

Census: unexplained top-level, non-overlay keys, per type

Instrument. The gate's own helper block, meaning bytes 0 up to the gate file's first line-start describe(, is copied verbatim into a throwaway probe beside the gate and deleted after each run. The probe asserts two controls: LIT (name is offered by 17 of 17 forms) and DARK (a fabricated key is offered by 0 forms and declared by 0 schemas). Identity check: the same slicer at the predecessor's landed commit 736c63a85 yields sha256 7b97432d8408…a50, which is the sha its report 5825062779 records. Before is 2aa25efb4, after is 096a8dbab.

type root kind offered not offered (overlay incl.) overlay root ledger rows before → after unexplained at 736c63a unexplained before → after
object object 17 26 8 3 → 3 15 15 → 15
field object 50 24 7 2 → 3 15 15 → 14
hook object 14 7 7 0 → 0 0 0 → 0
report object 13 8 8 0 → 0 0 0 → 0
dataset object 8 8 8 0 → 0 0 0 → 0
view union 29 57 8 5 → 5 43 44 → 44
app object 12 10 8 1 → 1 1 1 → 1
dashboard object 10 8 8 0 → 0 0 0 → 0
position object 4 8 8 0 → 0 0 0 → 0
action object 30 15 7 1 → 1 7 7 → 7
page object 14 9 7 1 → 1 1 1 → 1
agent object 15 9 8 1 → 1 0 0 → 0
tool object 6 8 8 0 → 0 0 0 → 0
skill object 8 8 8 0 → 0 0 0 → 0
flow object 13 8 8 0 → 0 0 0 → 0
permission object 11 9 8 0 → 0 1 1 → 1
email_template object 13 8 8 0 → 0 0 0 → 0
all 17 230 132 14 → 15 83 84 → 83
object-rooted (16, all but view) 40 40 → 39

After the row, the object-rooted residue is exactly the 39 keys that 5825062779 lists as carded on #19332, with 0 extra and 0 missing:

  • object (15): access, actions, activityMilestones, external, fieldGroups, highlightFields, indexes, listViews, publicSharing, requiredPermissions, searchableFields, stageField, systemFields, tenancy, userActions
  • field (14): accept, currencyConfig, dependsOn, inlineColumns, inlineEdit, lookupColumns, lookupFilters, readonlyWhen, relatedList, relatedListColumns, requiredPermissions, requiredWhen, storage, visibleWhen
  • app (1): contextSelectors
  • action (7): aria, bodyExtra, description, errorMessage, patch, requiredPermissions, resultDialog
  • page (1): slots
  • permission (1): adminScope

Before the row, it was those 39 plus field.format.

What moved since the predecessor's reading. The object-rooted set is identical (40 then, 40 before this row). The all-type count went from 83 to 84, and not-offered-with-overlay from 229 to 230. The only difference is view.options, the legacy per-kind bag that the flattened list overlay now declares (6a4aec71d). view.owner and view.hidden still count at the view root. 3cb84d084 retired them on the view-item shape, but the flattened overlay member (flattenedViewOverlayFields()) still declares both live, which that change left alone by design. view stays outside the top-level direction until its per-arm forms exist.

Re-read on today's origin/main 4e0f72e8d. I ran the same probe in a throwaway worktree, without the row and then with this commit cherry-picked. The readings are identical to the table: 40 → 39 object-rooted, 84 → 83 all types, and the same per-type numbers. The gate file has not moved on main, so this branch is not merged.

Ablation

  • Tool and run. scripts/ablation-replace.mjs in WRAP mode, run under the verify lock. The anchor is the whole new row (lines 363–369). It hit once, the marker count went 1 → 0, and the blob went c607298ce3d0 → d320ed81cb8c.
  • What the wrapped run showed. It regenerated the probe from the mutated file. The census saw field.format again: object-rooted 39 → 40, extra [field.format]. The gate stayed green at 54 of 54. That is the measured meaning of "unwired": no assertion reads the top-level direction yet.
  • Restore. The trap-armed restore brought the blob back to c607298ce3d0, equal to HEAD, and git diff HEAD came back empty.

Verification (all at 096a8dbab)

  • Census probe and gate. 2 files, 54 tests passed, under the lock, VERDICT command-exit 0.
  • Full spec suite. pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2 passed: 552 files, 16255 tests, 1 todo.
  • Spec typecheck. pnpm --filter @objectstack/spec typecheck exited 0: tsc, scripts-typecheck, and test-typecheck holding its pinned debt.
  • Gates. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 77 commands, and all 77 exited 0.
    • Four first exited 3 with PREREQUISITE NOT MET: check:doc-formula-expressions, check:dual-build-cjs-loads, check:lean-entry-closure and check:type-check-debt.
    • After a closure build (turbo, 71 of 71 successful, under the lock), all four re-ran and exited 0.
    • --ran reports 77 derived, 77 run, 0 NOT-MEASURED, 0 UNRUN.
  • Lint, narrowed with measurement. Running eslint --no-inline-config --format json on the one changed file gave 1 file, 0 errors and 0 warnings. --print-config resolves a config for the file, so it is inside the linted population. The config has no parserOptions.project and no projectService, and it has 4 rules, none of them type-aware. So this edit cannot move any other file's verdict.

Changeset

None is owed, because the change is test-only and publishes nothing. npm pack --dry-run of @objectstack/spec lists 2027 files and 0 *.test.ts. The positive controls liveness/field.json and src/data/field.zod.ts are present, and the changed path is absent (files[] ships only src/**/*.zod.ts from src). This repo refuses an empty-frontmatter changeset (check:empty-changeset), and Check Changeset exempts a PR only through the skip-changeset label. So that label is the route here (AGENTS.md Post-Task Checklist step 3).

Acceptance notes

  • The ledger's view reading block says owner and hidden "are no longer writable at all". That holds for the view-item shape only. At the view root, the flattened overlay member still declares both, and the census counts them. The block is not edited here, because it is outside this claim's scope. It is for whoever takes the first per-arm view form.
  • The section comment above the root-coordinate describe block still cites 274 top-level keys, 132 of them overlay. That is a historical figure, and today's reading is 230 / 132. It is not edited here.

Generated by Claude Code

The one top-level zod-only key that fit none of the reconciliation
ledger's recorded-reason groups gets a root omit row under a fifth,
renderer-owned-vocabulary group, whose admission test is written at the
head of the group. Each premise of the reason was read on this tree: the
spec declares no vocabulary for the key (a bare string, and its describe
says so), its one server-side meaning is the autonumber shorthand that
resolveAutonumberFormat reads only after autonumberFormat, which the
field form and the object designer's fields grid both offer, and the
remaining words are display hints the renderers declare.

Test-only: no schema, form, describe, liveness row or generated artefact
changes.

Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4e0f72e8d24c67705ce278de24bc4166ba3f1cd6 → packageMentionDocs.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 21:38
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit 53a5fef Sep 27, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19333-field-format-recorded-reason branch September 27, 2026 22:00
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…etadata form (objectstack-ai#20362)

Part of objectstack-ai#19332
Clause-②: no

## What this changes

One file changes:
`packages/spec/src/system/metadata-form-zod-reconciliation.test.ts`,
+314 / -1. It adds 11 root `omit` rows to the reconciliation `LEDGER`,
under three new reason classes, and one `describe` block of four
admission tests for those classes. No schema, form, `describe()`,
liveness row or generated artifact changes.

This is the ledger flight of ruling record `5861442317` (letter A on
groups G3, G4 and G5; objectstack-ai#19332). The G1 and G2 form-row flights are still
to come, which is why this PR says `Part of`.

It does **not** wire the top-level `zodOnly` direction. That is objectstack-ai#19333
item 2, which comes after every flight. It also leaves out
`action.aria`, because objectstack-ai#20323 is re-grading its liveness verdict.

| class (the `why` opens with it) | rows | what admits a row |
|:--|:--|:--|
| authored through its own editor | `object.actions` (the `action`
type), `object.listViews` (the `view` type, per the objectstack-ai#19330 ruling),
`page.slots` (the page designer), `object.external` (the import flow) |
the ruling's key list; **and**, when the editor is a registered metadata
type, the key must store that type's own node |
| code-declared platform configuration | `object.tenancy`,
`object.systemFields`, `app.contextSelectors`, `action.resultDialog` |
the ruling's key list only (see below) |
| no registered widget spells this union | `object.stageField`,
`field.inlineEdit`, `field.relatedList` | the ruling's key list; **and**
the node must be a union with an arm `FormSelectOptionSchema` refuses as
an option `value` |

Each row's `why` is one sentence. It names the editor, the writers or
the union arms, and it cites the ruling record, with the tracker number
beside it as a link.

## The admission tests (file lines 1251–1454)

These five root reasons were already in the file: platform-written,
deprecated alias, not enforced yet, overlay, renderer-owned vocabulary.
Each of them is read off the key itself. The three new ones were
**ruled**: for each key, the maintainer chose between a form row and a
recorded reason. So the first admission is the ruling's own key list,
transcribed as `RULED_ROOT_REASONS`.

**Test 1: each class holds exactly the keys the ruling put in it.** A
row that gives a ruled reason for any other key fails, and so does a
ruled key that has lost its row. A key that a form offers later already
has to drop its row, through the existing resolve test. The same edit
then takes it out of the list.

Where this package can read the class off the key's own node, the row
must pass that reading too:

- **Own editor: `editorTypeOf(type, key)`.** This returns the registered
type whose own node the key stores, judged by node identity. It is never
judged by name or by overlapping key sets. `object.actions` reads as
`action`, because its element is `getMetadataTypeSchema('action')`.
`object.listViews` reads as `view`, because its value is the node the
`view` container declares under `listViews`. The existing
`object.fields` subset row reads as `field`, and it serves as the lit
control. For the dark controls, `object.tenancy` and `object.access`
read as nothing. `page.slots` and `object.external` also read as
nothing, and that is a measurement, not an assumption: the page designer
lives in objectui, and the import flow lives in the datasource service.
So those two rows rest on the ruling alone.
- **Union: `unspellableArmValuesOf(type, key)`.** The node must be a
union, and it must have an arm whose value
`FormSelectOptionSchema.safeParse` refuses. That refusal is read from
the schema, not from a list kept in the test. The lit controls are
`stageField`, which returns `[false]`, and `inlineEdit`, which returns
`[true, false]`. The dark controls are `object.requiredPermissions`, a
union with no such arm, which returns `[]`, and `object.tenancy`, which
is not a union. The half that says no widget renders the whole union is
objectui's registry, and it rests on the ruling (the objectui readings
are below).
- **Code-declared platform configuration.** No reading in this package
admits it. The four keys' `describe()` text does not say "platform
only", and their writers are platform objects declared in other
packages. A spec-package test could not read those writers without
becoming a cross-package test. So this class is admitted by the ruling's
key list alone, and each row names the writers I measured.

**One dark control is deliberate: `object.systemFields`.** Its `false`
arm passes the union reading (`[false]`), but the ruling put it in
platform configuration. The test pins that fact, so the reading alone
never decides the class.

## Mechanism assumptions (PM), measured

1. **Population on today's main: holds.** I re-derived it with the
gate's own helper block, sliced verbatim into a throwaway probe beside
the gate, with the prefix checked byte-identical on disk. The probe was
deleted after each run.
- Controls: LIT `name` is offered by 17 of 17 forms and declared by 17
of 17 schemas. DARK, a fabricated key, is offered by 0 and declared by
0.
- At base `c74de10a9` (PM's `826f3279c` is an ancestor), the
object-rooted residue is **39**, the ruling's 39. None were added,
removed or retired, and all 11 keys of this flight are in it. The
liveness rows are `live`, or drilled with every child `live`.
- After the rows, the residue is **28**. Exactly the 11 keys left it,
and none came in. All types: 81 → 70 (`view` 42). Root rows: 15 → 26.
2. **Root-coordinate discipline: partly false as stated.**
- No existing root reason is admitted by a *test*. The phrase "its
admission test heads its group below" refers to the group's head
**comment** (the renderer-owned criteria). The three new classes get the
file's first real admission tests.
- Two of them are partly mechanical, as set out above. The
platform-configuration class is not mechanical at all: it rests on the
ruling's key list, and I say so plainly.
- A reading I tried and dropped: "no registered form row sits over such
a union" is **false**. The forms already spell one arm of unions with a
boolean arm: `object.fields.unique` (`type: 'boolean'`), `field.unique`
(derived), `action.visible` and `action.disabled` (`textarea`), and
`dashboard.globalFilters.defaultValue`. So that fact cannot be the
class's reading.
3. **Wording: holds.** Each row is one sentence naming the concrete
editor, writers or arms, and each cites `5861442317`.

## What the rows rest on, measured

Framework readings are at `154da111e`. objectui readings are at the
`.objectui-sha` pin `f8a9d0fb0596`, from a shallow clone. HotCRM
readings are at `2f7b2326`.

- **The page designer exists.** objectui
`app-shell/.../inspectors/PageBlockInspector.tsx:387` writes `slots: {
...slotsObj, [slotName]: nextArr }`.
- **The import flow exists.**
- REST routes: `POST /datasources/:name/external/tables/:remote/draft`
and `/import` (`packages/rest/src/external-datasource-routes.ts:31-32`).
- The draft writes `external: { remoteSchema?, remoteName }`
(`packages/services/service-datasource/src/external-datasource-service.ts:486`,
`:931-932`).
- Callers: the Studio's `ImportObjectDialog.tsx` and `os datasource
introspect`.
- **List views are authored through the view type.** HotCRM writes
`listViews` in 13 files, and every one is a `*.view.ts`. The examples
write them in 9 files, all `*.view.ts`.
- **Platform-configuration writers.** Examples and HotCRM author none of
the four keys; the HotCRM lit control `fieldGroups` hits 18 files. The
code writers are:
  - `tenancy`: `sys_api_key`, `sys_sso_provider`;
  - `systemFields`: `sys_metadata_activation` (`{ tenant: false }`);
  - `contextSelectors`: `studio.app.ts`;
- `resultDialog`: actions on `sys_user` (4), `sys_two_factor` (2),
`sys_oauth_application` (2), `sys_sso_provider` (1).
- **The union bounds.**
- `FormSelectOptionSchema` refuses `true` and `false` as an option
`value`, and accepts `primary`, `grid` and `form` (measured by
`safeParse`).
- objectui `WIDGETS` (`widgets.tsx:2909`) registers 20 widgets, and none
of them is a union selector.
- `resolveUnionBranch` falls back to the first branch in create mode
(`SchemaForm.tsx:238-250`).

## Ablation

Every leg ran from committed state (`154da111e`, blob `d2fe718f77e5`)
through `scripts/ablation-replace.mjs` in WRAP mode, under the verify
lock. In every leg, the anchor hit once, the mutation was verified on
disk (anchor count and blob change), and the gate plus a probe
regenerated from the mutated file ran. The restore was proven each time:
blob equal to HEAD, and `git diff HEAD` empty. The subject is imported
from `src` by relative path, so there is no `dist/` between the mutation
and the run.

| leg | residue (28 before) | gate |
|:--|:--|:--|
| delete the `object.external` row (own editor) | 29, adds
`object.external` | 1 red: *each class holds exactly the keys…* |
| delete the `action.resultDialog` row (platform config) | 29, adds
`action.resultDialog` | 1 red: the same test |
| delete the `field.relatedList` row (union) | 29, adds
`field.relatedList` | 1 red: the same test |
| swap `key: 'actions'` → `'access'` | 28 (`object.actions` in,
`object.access` out) | 2 red: the list test, and the own-editor test
(`object.access: the ruling names no editor for this key`) |
| swap `key: 'tenancy'` → `'access'` | 28 (`object.tenancy` in,
`object.access` out) | 1 red: the list test |
| swap `key: 'inlineEdit'` → `'readonlyWhen'` | 28 (`field.inlineEdit`
in, `field.readonlyWhen` out) | 2 red: the list test, and the union test
(`field.readonlyWhen: an option can spell every arm`) |

Every other test stayed green on every leg (57 of 58, or 56 of 58). The
resolve test stayed green on the swaps, because the swapped-in keys are
authorable and unoffered.

## Verification (head `154da111e`)

- **Gate and census probe:** 2 files, 58 tests passed, under the lock,
`VERDICT command-exit 0`. This ran at `8f31b4a5d`; the gate file's blob
there, `d2fe718f77e5`, is byte-identical at `154da111e`, and the
ablation legs re-ran the same pair at `154da111e` before each mutation's
verdict.
- **`@objectstack/spec` build:** exit 0. The tree stayed clean: no
`authorable-surface.base.json` or other artifact movement.
- **`pnpm --filter @objectstack/spec typecheck`** (tsc,
scripts-typecheck and test-typecheck): exit 0.
- **`pnpm --filter @objectstack/spec exec vitest run --project local
--maxWorkers=2`:** 554 files, 16431 passed, 1 todo, exit 0. The gate
file is in the `local` project; it is not in `vitest.repo-tests.json`.
- **`pnpm --filter @objectstack/spec test:repo`** (`--project repo`, run
under the lock as the seat asked): 33 files, 604 passed, `VERDICT
command-exit 0`.
- **Gates:**
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 78 commands. Each was run with its
exit code captured after a redirect, never through a pipe. **74 exited
0.**
- Four exited 3, `PREREQUISITE NOT MET` on other packages' `dist/`:
`check:doc-formula-expressions`, `check:dual-build-cjs-loads`,
`check:lean-entry-closure` and `check:type-check-debt`. The objectstack-ai#20064 and
objectstack-ai#20322 precedents hit the same four families.
- The whole-closure build that would satisfy them queued for about 18
minutes under the shared lock, behind other seats' heavy runs, and never
acquired. So these four are **NOT MEASURED** locally, a declared
narrowing. They read built output of packages this test-only diff does
not touch, and CI runs them.
- `--ran` reports `78 derived famil(ies) accounted for — 74 run, 4
NOT-MEASURED (4 DERIVED from a recorded exit 3)`, exit 0.
- **Lint, narrowed with measurement:**
- `eslint --no-inline-config --format json` on the one changed file
reports 1 file, 0 errors and 0 warnings.
- `--print-config` resolves a config for the file, so the file is inside
the linted population.
- That config has no `parserOptions.project` and no `projectService`,
and it enables 4 rules, none of them type-aware. So this edit cannot
move any other file's verdict.

## Changeset

None is owed. The change is test-only and publishes nothing. `npm pack
--dry-run` of `@objectstack/spec` lists 2027 files, and none is a
`*.test.ts`. The changed path is absent from the list. The positive
controls, `src/ui/view.zod.ts` and `liveness/object.json`, are present.
The no-release route is the `skip-changeset` label, as on objectstack-ai#20064.

## Acceptance notes

- **`object.external.introspectedAt` claims a writer that does not write
it.** Its describe says "Set by `os datasource introspect`", but that
command's draft path writes only `remoteSchema` and `remoteName`
(`external-datasource-service.ts:486`, `:931-932`). This is a source
reading only; I did not run it. Carrier: none. The own-editor reading
deliberately does not lean on that describe.
- **The forms already spell one arm of a boolean-armed union.**
`field.unique` and `object.fields.unique` offer only the boolean arm:
`object.form.ts:130` has `type: 'boolean'`, and `field.form.ts:26` is
derived, so the first arm is the boolean. That makes `'global'` /
`'organization'` (ADR-0120) unreachable from those Studio rows. It is
the same shape the ruling refused as option C for G5. I read it from
source and did not browser-run it. Carrier: the G2 flight that curates
`indexes.unique`, or whoever builds a union-aware control.
- **`action.aria` is still in the 28-key residue.** objectstack-ai#20323 carries it.
- The section comments still quote the census figures ("274 … 132").
They are historical and were left as written, as the precedents did.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ)_

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

protocol:system size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants