Skip to content

fix(cli): give every FieldType member a generate.ts vocabulary entry, and make the next gap loud - #14834

Merged
os-project-manager merged 4 commits into
mainfrom
claude/issue-14657-generate-field-type-vocabularies
Sep 3, 2026
Merged

fix(cli): give every FieldType member a generate.ts vocabulary entry, and make the next gap loud#14834
os-project-manager merged 4 commits into
mainfrom
claude/issue-14657-generate-field-type-vocabularies

Conversation

@os-trump

@os-trump os-trump commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes #14657

Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

generate.ts carries three hand-authored field-type vocabularies. None was ever checked against the FieldType enum it describes, and a real member with no entry did not error — it took the default. The scaffold then emitted a plausible-looking wrong schema with nothing to tell the author.

This PR closes both halves: every member now has an answer, and the next member that does not is loud.

The population, re-measured — not taken from the card

The card said "21+" and the triage comment could not reproduce it. Both are hand-carried lists, which is the artefact this card is about, so the set was derived programmatically at claim time: the FieldType enum and all three vocabularies read in ONE script, using the same extractors the pin test uses.

$ npx tsx measure.mts        # imports FieldType from packages/spec/src/data/field.zod.ts,
                             # reads the three vocabularies out of generate.ts by source
FieldType members: 49
vocabularies found: ["FIELD_TYPE_MAP","FIELD_TYPE_SQL_MAP"] switches: 1
sizes: FIELD_TYPE_MAP=28 FIELD_TYPE_SQL_MAP=28 switch=25
missing from FIELD_TYPE_MAP (21): secret, toggle, radio, checkboxes, tree, user, avatar, video,
  audio, summary, composite, repeater, record, location, address, code, slider, signature,
  qrcode, progress, tags
missing from FIELD_TYPE_SQL_MAP (21): (the same 21)
missing from migration switch (24): the 21 above, minus user, plus image, file, autonumber, vector
missing from ALL THREE (20) · missing from AT LEAST ONE (25)

The card's numbers hold exactly: 49 members, 21 / 21 / 24 missing, 25 members affected in at least one vocabulary. After this PR the same script reports 49 / 49 / 49 and every "missing" list empty.

The three vocabularies, on the merge base (5a5336b39)

# vocabulary what it decides site fallback
1 FIELD_TYPE_MAP the TypeScript type os generate types emits generate.ts:471 || 'unknown' at :503
2 FIELD_TYPE_SQL_MAP the SQL column type os generate migration --format sql emits generate.ts:880 || 'TEXT' at :912
3 the switch (fType) in generateMigrationTs the knex builder call os generate migration emits (typescript, the DEFAULT format) generate.ts:1003, in the function at :962 default: at :1042

Three is the right number and they stay three. They answer three different questions and their answers legitimately differ (text is string / VARCHAR(255) / table.string; multiselect is string[] / TEXT / table.jsonb). What did NOT need to stay three is the COMPLETENESS CHECK: one rule, one authority (FieldType), applied to all three. No merge, no behaviour change — see "what I did not do".

The mechanism: totality derived from the enum, in two places

Type level, for the two lookup tables. Each is now closed by } satisfies Record<FieldType, string>;. A field type added to the spec is a named tsc error here — Property 'x' is missing in type … but required in type Record<FieldType, string> — and this is evaluated for real, not a phantom check: packages/cli has "typecheck": "tsc --noEmit", its tsconfig.json includes src, and tsc --listFiles puts 2 of 2 of this PR's edited files inside that program. It also enforces the #13871 direction for free: a key outside the enum is an excess-property error.

Test level, for all three. The switch cannot carry a satisfies — its scrutinee is a plain string off a possibly-unvalidated config — so generate-field-type-vocabulary.pin.test.ts walks the real enum and names any member a vocabulary does not cover. It already read all three out of the source, so this is the second half of an assertion it was built for.

Deliberately NOT chosen: an annotated exclusion list of members allowed to stay on the fallback. A hand-kept list checked by another hand-kept list is the same defect one layer up. A member that genuinely wants the default gets an EXPLICIT entry spelling the default out, so the decision is written down instead of being an absence — the assertion message says exactly that.

The pin also pins the annotation itself: the extractor REQUIRES } satisfies Record<FieldType, string>; as each table's terminator, so deleting the type-level half cannot quietly demote it to nothing.

Red-first proof

Three mutations, each confirmed on disk before the run and each restore proven by blob-hash equality, under trap '<restore>' EXIT INT TERM on absolute paths. The implementation was committed FIRST, so HEAD is a real restore point.

No rebuild leg applies to A and B: the pin reads generate.ts as SOURCE TEXT (fs.readFileSync of src/commands/generate.ts via import.meta.url), not through any package exports; the one dist dependency is FieldType from @objectstack/spec, which was built first and which no mutation touches. Mutation C is the type-level half and DID run against the fully built workspace closure (turbo run build --filter='./packages/*' --filter='./packages/*/*', exit 0).

HEAD blob      : 4afa4d5d0f7414eec27048228e2f157156cfc713
worktree blob  : 4afa4d5d0f7414eec27048228e2f157156cfc713   (equal ⇒ committed restore point)

MUTATION A — delete `secret: 'string',` from FIELD_TYPE_MAP  [the pin]
  anchor occurrences before=1 after=0 ; mutated blob 930c05808d64963c58e327998d3690b920f8ad94
  pin exit = 1 · Tests 1 failed | 8 passed
  AssertionError: FIELD_TYPE_MAP (os generate types) has no entry for these real FieldType
  members, so each one silently takes the generator default (TS `unknown` / a TEXT column).
  Add an entry — or, if the default is genuinely the right answer for it, say so with an
  explicit entry that spells the default out, so the decision is written down rather than
  left as an absence.: expected [ 'secret' ] to deeply equal []
RESTORE A: blob 4afa4d5d0f7414eec27048228e2f157156cfc713 == HEAD · git diff HEAD = 0 bytes

MUTATION B — delete the `satisfies` annotation from FIELD_TYPE_MAP  [the pin]
  anchor occurrences before=2 after=1 ; mutated blob 91c1ec02453cd09ec16e3e21469bf9a4a58d69c3
  pin exit = 1 · Tests 3 failed | 6 passed
  Error: FIELD_TYPE_MAP in generate.ts must be closed by `} satisfies Record<FieldType,
  string>;`, but it is closed by `};`. That annotation is the type-level half of the #14657
  rule that every FieldType member has an entry: without it, adding a field type to the spec
  stops being a compile error here and goes back to silently generating `unknown` / a TEXT
  column.
RESTORE B: blob 4afa4d5d0f7414eec27048228e2f157156cfc713 == HEAD · git diff HEAD = 0 bytes

GREEN AGAIN on the restored tree: Test Files 1 passed · Tests 9 passed

MUTATION C — delete `secret: 'string',` again, on the BUILT closure  [the compiler]
  anchor occurrences before=1 after=0 ; mutated blob 930c05808d64963c58e327998d3690b920f8ad94
  pnpm --filter @objectstack/cli run typecheck: exit 0 before  →  exit 2 mutated
  src/commands/generate.ts(559,3): error TS1360: Type '{ text: string; … }' does not satisfy
  the expected type 'Record<"number" | "boolean" | "tags" | … | "vector", string>'.
    Property 'secret' is missing in type '{ text: string; … }' but required in type
    'Record<"number" | "boolean" | … | "vector", string>'.
  559 } satisfies Record<FieldType, string>;
          ~~~~~~~~~
RESTORE C: blob 4afa4d5d0f7414eec27048228e2f157156cfc713 == HEAD · git diff HEAD = 0 bytes

Mutation B's first draft failed only on a bare toHaveLength(2) — "expected [ Array(1) ] to have a length of 2", which tells an author nothing. The extractor was rewritten to bound each table at its OWN closing line and name the cause; the message above is the second run. A guard you cannot show failing LEGIBLY is only half a guard.

The values are measured, not invented

Each answer is the shape the platform actually implements: the spec's ADR-0104 D1 value classes (packages/spec/src/data/field-value.zod.ts) decide the class, and driver-sql's DDL emitter (packages/drivers/driver-sql/src/sql-driver.ts), which creates the real columns, decides the shape.

class (ADR-0104 D1) members added TS SQL knex why
STRING_VALUE_TYPES secret string VARCHAR(255) table.string the ROW holds an opaque sys_secret ref, not the credential (ADR-0100); the driver falls to its default table.string
STRING_VALUE_TYPES (text family) code, signature, qrcode string TEXT table.text sql-driver.ts cases these three into its text family (#11794, #11875); any declared maxLength is enforced at the write seam, not by the column
BOOLEAN_VALUE_TYPES toggle boolean BOOLEAN table.boolean shares the driver's boolean/toggle arm
SINGLE_OPTION_TYPES radio string VARCHAR(255) table.string one option code, exactly like select
MULTI_OPTION_TYPES checkboxes, tags string[] JSONB table.jsonb arrays; the class seeds driver-sql's JSON_COLUMN_TYPES
NUMERIC_VALUE_TYPES slider, progress, summary number DECIMAL(18,2), DECIMAL(5,2), DECIMAL(18,2) table.decimal valueSchemaFor gives all three z.number(); the driver gives them float columns. progress takes percent's narrower shape — same 0-100 quantity
REFERENCE_VALUE_TYPES user, tree string VARCHAR(36) table.string the stored value is the TARGET's id. table.string, not table.uuid: a platform id is a 26-char ULID and Postgres refuses one in a uuid column — the existing user arm already says this
FILE_REFERENCE_TYPES avatar, video, audio (+ image, file in the switch only) string VARCHAR(2048) table.string the ADR-0104 D3 stored form is an opaque sys_file id string (FileReferenceIdValueSchema), which is why image / file were already string / VARCHAR(2048). One class, one answer
STRUCTURED_JSON_TYPES composite, repeater, record, location, address (+ vector in the switch only) see below JSONB table.jsonb embedded structured values stored as JSON on the parent row
COMPUTED_VALUE_TYPES autonumber (switch only) (already string) (already SERIAL) table.string the runtime issues a RENDERED string — prefix + counter + suffix — and the driver emits table.string. A SERIAL could not hold INV-0001

Two details worth calling out, both from the triage comment's split conditions:

The embedded-structured family is answered ONCE, as triage asked — one rule for composite / repeater / record / address, with location and json in the same class. Their TypeScript types point AT the spec's own exported types rather than transcribing them (location: 'Data.LocationValue', address: 'Data.AddressValue' — the generated file already imports * as Data), so the emitted interface cannot drift from the value contract. composite / repeater / record take Record<string, unknown> shapes matching valueSchemaFor's branches, because their sub-field shape is per-field and not nameable from the enum.

Members I refused to map: none. Every one of the 25 had a measured answer in the spec or the driver, so "refuse loudly" was never the honest outcome for any of them. What I DID refuse is below.

What I did NOT do, and why

File face

packages/cli/src/commands/generate.ts · packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts · .changeset/generate-field-type-vocabulary-totality.md · packages/runtime/src/dispatcher-error-vocabulary.ts.

None of the four packages/cli PRs in flight from this seat is touched: #14809 (src/commands/explain.ts, test/commands.test.ts), #14812 (src/commands/serve.ts), #14705 (src/commands/create.ts), #14710 (tsconfig.json, package.json, tsconfig.test.json, test-typecheck-debt.json).

The one file outside packages/cli needs its own justification. Adding code: 'TEXT' to FIELD_TYPE_SQL_MAP turned check:dispatcher-error-vocabulary red: the code FIELD TYPE's key is spelled code and its value happens to be upper-case, so the scan reads it as an ADR-0112 error-code stamp. [unclassified-site] packages/cli/src/commands/generate.ts stamps unregistered code 'TEXT' (objlit). The gate's own designed repair is a classification row, and the fitting verdict already exists — foreign-vocabulary, "belongs to a different vocabulary that merely spells itself code". One row added, door: 'none'; the gate goes from 1 finding(s) to OK — 61 unregistered code-stamping site(s), all classified. The alternative (quoting the key to dodge the scanner) would have hidden the site from a gate rather than answering it. packages/runtime's own typecheck and error-envelope.conformance.test.ts — the one test that imports UNREGISTERED_CODE_SITES — are both green below.

Changeset: patch, measured

packages/cli's files whitelist is ["dist","README.md","CHANGELOG.md"], so a src/** COMMENT would publish nothing — but this changes what os generate types and os generate migration EMIT, and that behaviour reaches dist. A released package whose behaviour changes takes a patch. dispatch-gates.mjs prints the fork as "write one unless this card publishes nothing from any released package"; this card publishes.

Local verification — all green at 8c3049a9

The union below ran at 8c3049a9 (git rev-parse --short HEAD at the time of the run; nothing has been committed since, and the tree was clean). The gate family was derived from the real change set with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack — 29 families by path and kind, 37 runnable commands once the changeset existed — not from a remembered list. Every exit code was captured BEFORE any pipe, and each verdict is read from the gate's own printed line.

Build and package verdicts

turbo run build --filter='./packages/*' --filter='./packages/*/*'    exit 0
pnpm --filter @objectstack/cli run typecheck                          exit 0
pnpm --filter @objectstack/cli test        Test Files 236 passed (236) · Tests 2738 passed (2738)
pnpm --filter @objectstack/runtime run typecheck                      exit 0
runtime error-envelope.conformance.test.ts Test Files   1 passed (1) · Tests   53 passed (53)

All 37 derived gate commands: green. The 21 pnpm check:* families and the 16 direct node scripts/… families, including the five that need a built workspace — check:type-check-coverage, check:type-check-debt (OK — 21 ledger entr(ies) re-measured in 213.7s, 549 raw tsc error(s) total, none above its recorded number), check:dual-build-cjs-loads (this run 102/66/610/1 · floors 90/58/520/1), check:i18n (OK (9 package(s) — all bundles in sync, no undeclared authoring keys)) and check:i18n-coverage.

Two of them had to be re-run to get a real reading, and both are worth recording:

  • check:dispatcher-error-vocabulary was genuinely RED on the first pass and is green after the classification row — see the file-face section.
  • check:test-completeness and scripts/pm/check-half-states.mjs exit 3 when run without their inputs — their own documented "nothing was measured" code, not a finding. Both are green in the run above only in the sense that CI supplies what they need; locally they were re-read as NOT MEASURED rather than counted as passes.

Getting here took three attempts at the shared verify lock — two full 540s budgets returned VERDICT queue-timeout (exit 99) · never acquired behind four other seats (issue-14758, issue-14642, issue-14573, plus @objectstack/runtime^... and @objectstack/rest^... builds); the third acquired after 511s and held for 1950s.


🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

…t gap loud

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…guard is removed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
… vocabulary

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/runtime, touching 5 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/data-flow.mdx (via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/deployment/cli.mdx (via os generate (command, read off packages/cli/src/commands/generate.ts))
  • content/docs/protocol/kernel/lifecycle.mdx (via os generate (command, read off packages/cli/src/commands/generate.ts))
What this run could not see
  • 23 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 37 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8packageMentionDocs.

Which tree this was computed on

This run read content/docs from 315a485d9fa97e66b48a10af2971e241a67c98ba — the merge of head 5fe7ab8d815d1f506e2afff45ee38726256c1c3d into base f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 315a485d9fa97e66b48a10af2971e241a67c98ba && git checkout 315a485d9fa97e66b48a10af2971e241a67c98ba
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 5fe7ab8d815d1f506e2afff45ee38726256c1c3d && git checkout -B drift-repro f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 && git merge --no-ff 5fe7ab8d815d1f506e2afff45ee38726256c1c3d

node scripts/docs-audit/affected-docs.mjs --json f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-project-manager
os-project-manager added this pull request to the merge queue Sep 3, 2026
Merged via the queue into main with commit 431979e Sep 3, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14657-generate-field-type-vocabularies branch September 3, 2026 10:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

3 participants