Skip to content

feat(objectql): enforce the select idiom — the option marked default: true is the field default (#7246) - #7388

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-7246-enforce-select-option-default
Aug 10, 2026
Merged

feat(objectql): enforce the select idiom — the option marked default: true is the field default (#7246)#7388
os-zhuang merged 2 commits into
mainfrom
claude/issue-7246-enforce-select-option-default

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Closes #7246

Executes the maintainer ruling of 2026-08-10 (comment 5237213604): enforce. That ruling post-dates and supersedes the 06:50Z delegated retire-ruling, which had declared its own veto condition ("a maintainer word reverses this at zero cost").

SelectOption.default was authorable, spec-valid, and read by nothing on the insert path. Its only consumer anywhere in the repo was lint's isNullableField, which concluded from it that a column was always valued — a build-breaking verdict resting on a declaration the engine did not honour. This makes the declaration real, which is what makes that heuristic honest.


1. Premise check — re-verified on origin/main before implementing

Claim in the card Verdict Evidence on origin/main
applyFieldDefaults resolves f.defaultValue and never reads options CONFIRMED packages/objectql/src/engine.ts — the loop gates on if (f.defaultValue == null) continue;, then branches Expression envelope → current_userNOW() → literal. options appears nowhere in the method, and the entry type was literally { name; type?; defaultValue? }
A create that omits the field stores null, not the marked option CONFIRMED Reproduced as a failing test before the fix (see reverse verification, RV-1 cases 1–2)
The lint heuristic is the only consumer of the key CONFIRMED packages/lint/src/validate-expressions.ts isNullableField() is the sole reader. The SQL DDL never reads options at all — the only .options hit in driver-sql is AggregationFunction.options, a Zod enum's member list, unrelated
The verdict it feeds is build-breaking CONFIRMED It feeds buildNullableFieldIndex, whose findings are errors

Premise holds in full. No part of the card had expired.

Spec schema is untouched — the key already exists, so there is zero acceptance-face movement. packages/spec/, docs/adr/ and content/docs/releases/ have no changed files in this PR.


2. Corpus census — whose insert behaviour moves

Scanned the whole repo (comment-stripped, brace-balanced per field, so a container fields: block is not miscounted as a field). 31 fields declare an option marked default: true.

  • 30 declare no defaultValue → insert behaviour moves (null becomes the marked option)
  • 1 declares bothexamples/app-crm/src/objects/lead.object.ts:41 status, option new + defaultValue: 'new'. They agree, so nothing observable changes; it is now resolved by the defaultValue path.
  • 0 fields mark more than one option; 0 fields with multiple: true carry a marked option (the corpus's two multi-selects — app-todo task.tags, sys-webhook.triggers — mark none). Both of those shapes are still decided and pinned below, since the spec permits them.

The 30 whose behaviour moves, all status/stage/priority-style selects whose marked option is the intended initial state:

File Field New default
examples/app-crm/src/objects/account.object.ts:24 industry technology
examples/app-crm/src/objects/activity.object.ts:24 type call
examples/app-crm/src/objects/activity.object.ts:34 status planned
examples/app-crm/src/objects/lead.object.ts:57 source web
examples/app-crm/src/objects/opportunity.object.ts:29 stage prospecting
examples/app-showcase/src/data/objects/account.object.ts:53 industry technology
examples/app-showcase/src/data/objects/account.object.ts:75 status prospect
examples/app-showcase/src/data/objects/account.object.ts:85 sales_region amer
examples/app-showcase/src/data/objects/cascading-select.object.ts:77 tier standard
examples/app-showcase/src/data/objects/contact.object.ts:53 stage new
examples/app-showcase/src/data/objects/expense-report.object.ts:47 status draft
examples/app-showcase/src/data/objects/expense-report.object.ts:141 category other
examples/app-showcase/src/data/objects/expense-report.object.ts:156 status submitted
examples/app-showcase/src/data/objects/field-zoo.object.ts:62 f_select low
examples/app-showcase/src/data/objects/inquiry.object.ts:37 status new
examples/app-showcase/src/data/objects/invoice.object.ts:104 region amer
examples/app-showcase/src/data/objects/invoice.object.ts:115 status draft
examples/app-showcase/src/data/objects/preference.object.ts:22 theme light
examples/app-showcase/src/data/objects/preference.object.ts:30 default_landing my_work
examples/app-showcase/src/data/objects/preference.object.ts:38 email_digest daily
examples/app-showcase/src/data/objects/project.object.ts:44 status planned
examples/app-showcase/src/data/objects/project.object.ts:55 health green
examples/app-showcase/src/data/objects/semantic-zoo.object.ts:36 status draft
examples/app-showcase/src/data/objects/semantic-zoo.object.ts:95 status red
examples/app-showcase/src/data/objects/task.object.ts:44 status backlog
examples/app-showcase/src/data/objects/task.object.ts:58 priority medium
examples/app-showcase/src/data/objects/team.object.ts:43 engagement owner
examples/app-todo/src/objects/task.object.ts:29 status not_started
examples/app-todo/src/objects/task.object.ts:42 priority low
packages/qa/downstream-contract/src/account.object.ts:14 stage prospect

Every one is a change toward what the declaration always described, and 13 of them are additionally required — those inserts previously had to supply the value or be rejected, and now default cleanly.


3. Design points, decided explicitly

(a) multiple: true, and several options marked

Decision: multiple: true assembles an ARRAY of every marked option in declaration order. A single-valued field takes the FIRST marked option.

This is not a style preference — it is what the surrounding contracts already require, measured rather than assumed:

  • FieldSchema.multiple is documented "Allow multiple values (Stores as Array/JSON)", so the default's shape must follow the field, not the number of marked options. One marked option on a multi-select is therefore a one-element array, never a bare scalar.
  • validateRecord's multi-value branch rejects a non-array outright (invalid_type_array, packages/objectql/src/validation/record-validator.ts). A scalar default on a multiple: true field would have failed the engine's own validator on the very insert it was meant to complete. This is the measurement that settles the question.
  • The Field.select builder normalizes option value to lowercase and does not dedupe or refuse the default flag, so "several marked on a single-valued field" is reachable metadata. One slot means declaration order decides — deterministic, and the option a picker preselects when it takes the first match.
  • Refusing (throwing) was rejected: the metadata is spec-valid, and a runtime throw on spec-valid input is a worse answer than a well-defined value. Ignoring multi-selects was rejected too — it would preserve, for that one shape, exactly the inertness this change removes, with a carve-out the author cannot see from the schema.

(b) SQL DDL — no physical column DEFAULT for an option-default

Decision: the DDL does NOT emit one. The asymmetry with a defaultValue literal (which is emitted) is the decision, not an omission, and it is recorded on SqlDriver.applyDeclaredColumnDefault so nobody "fixes" it by accident.

The #4560 engine-token discipline does not forbid emitting here — an option's value is a plain literal, not a runtime token, so it could legally be emitted. The reasons not to:

  1. One resolver owns the precedence. defaultValue beats the option flag, and that ordering lives in the engine. A column DEFAULT is a second resolver that fires only where the engine did not.
  2. The shape has no scalar DDL form. On multiple: true the default is an array; emitting would need an invisible "except for multi-selects" carve-out.
  3. It would divide deployments silently. applyDeclaredColumnDefault runs for a fresh column (createColumn) and a re-materialized one (rebuildSqliteTablePatched) — never a retrofit. Emitting would give new databases, and any SQLite rebuild, a DEFAULT that older databases on identical metadata lack. Schema-drift semantics checked: detectDrift's only default_mismatch producer is the data: the SQL driver emits the current_user framework token as a literal column DEFAULT, so the database itself writes a non-id into a lookup('sys_user') #4560 runtime-token check, and there is no general declared-literal-vs-physical comparison anywhere — so nothing would ever report that divergence.
  4. It would make SQL the odd driver out. The engine fallback serves memory, mongodb, sql and turso identically from day one; a SQL-only second enforcement point is precisely the per-datasource split that data: the SQL driver emits the current_user framework token as a literal column DEFAULT, so the database itself writes a non-id into a lookup('sys_user') #4560 and data: the NOW() defaultValue token is unresolved on non-SQL datasources — the engine stamps the literal string, and the record validator then rejects the insert #4597 were both about.

Consequence, stated in the tests rather than left implicit: only a writer bypassing the engine entirely sees NULL — and that writer is not reading options either.

(c) Presence semantics

The engine's own dv == null test governs. The fallback fires only when defaultValue is absent by that test, so defaultValue: '' is a real default and wins over the option flag — the same rule that makes a caller-supplied '' a real value. Reading presence as truthiness instead would silently store the marked option where the author declared "empty".

(d) Ordering

The fallback resolves inside the defaultValue == null arm, downstream of the token and envelope branches — after reading applyFieldDefaults' real structure. This is structural, not a name check: an option value can therefore never be handed to isCurrentUserDefaultToken / isNowDefaultToken. An option spelled current_user is a picklist entry and stores those twelve characters, not the acting user's id. Pinned by test.

Two further decisions worth naming

  • Type-agnostic, exactly like the lint heuristic. options is a field-level key on FieldSchema, not gated on type: 'select', and isNullableField reads it with no type test. Matching that keeps both sides honest by construction: there is no field lint calls always-valued that the engine leaves empty.
  • Canonical default spelling only. isDefault / selected are SelectOptionSchema authoring aliases, normalized before metadata reaches the engine. Honouring them here would move the alias contract out of the schema that owns it.

4. Reverse verification — direction predicted in writing first

RV-1: delete the engine fallback

Predicted 7 RED / 7 GREEN, per case, before running. Observed: exactly that, and the 7 reds were the 7 named cases.

× fills an OMITTED field with the marked option — the issue repro
× fills an EXPLICIT null too — the unpicked-control shape (#2706)
× an option spelled `current_user` stores the twelve characters, not the actor id
× `multiple: true` assembles an ARRAY of every marked option, in declaration order
× `multiple: true` with ONE marked option is still an array — shape follows the field
× a SINGLE-valued field takes the FIRST marked option — one slot, declaration order decides
× no `type` test — `isNullableField` has none either, so neither may have one
 Test Files  1 failed (1)
      Tests  7 failed | 7 passed (14)

The greens were predicted, not accidents — and three of them are an inverted direction worth stating plainly. The precedence pins (cases 5–7: defaultValue wins, defaultValue: '' wins, envelope wins) cannot go red when the fallback is deleted: they pin the winner of a two-key contract, and removing the loser does not move the winner. What they guard is the opposite future change — a fallback that starts outranking defaultValue. The remaining greens (3, 4, 12, 14) assert a caller-supplied value or an unset field, which is the reverted behaviour by definition. Reporting these as reds would have required a template-shaped fabrication.

RV-2: make the DDL emit an option-default (the rejected branch of decision (b))

Predicted 2 RED / 1 GREEN. Observed: exactly that — the pin genuinely guards the decision rather than passing vacuously.

× creates an option-defaulted column with NO database default
    AssertionError: expected '\'draft\'' to be null
× a driver-level insert that omits the field stores NULL — the engine, not the database, defaults it
    AssertionError: expected 'draft' to be null
 Tests  2 failed | 1 passed (3)

The green one is the defaultValue-still-emitted regression case, untouched by that branch, as predicted. The probe was reverted immediately (git checkout HEAD --); it appears in no commit.

RV-3: lint

No reverse verification is claimed, because there is nothing to revert. isNullableField's logic is byte-identical; only its docstring changed, to record what now grounds the select-idiom branch. Its suite is run below purely as a regression guard. Manufacturing a direction here would have been a fabrication.

Reverse verification used git checkout origin/main -- ... and git checkout HEAD -- ... throughout — never git stash, whose stack is shared across every worktree of the repo.


5. Gates and tests

Gate Result
@objectstack/objectql test 170 files, 2966 tests passed
@objectstack/driver-sql test 82 passed / 4 skipped (86 files), 1159 tests passed
@objectstack/lint test 70 files, 1852 tests passed
typecheck (all three) Done, no errors
ESLint (changed files) clean, no output
check:engine-double-contract OK — 130 pinned, 133 DEBT, 2 exempt
check:nul-bytes OK — 6742 files, no raw control bytes (plus a targeted self-scan of every changed file)
check:error-code-casing OK — 3400 files (ADR-0112)
check:route-envelope OK — 8 route modules
check:adr-0087-registration OK — no declared-breaking changeset
check:spec-parsed-alias OK — ADR-0122
check:empty-changeset OK

All heavy runs were serialized under flock /tmp/os-heavy-verify.lock with a capped heap and --maxWorkers=2.

Changeset: .changeset/select-option-default-enforced.md, @objectstack/objectql: minor, with a migration note. Level taken from precedent, not invented: qa-testing-liveness-enforce.md (the closest analogue — an ADR-0049 enforce leg) and autonumber-default-format-contract.md are both minor. lint and driver-sql carry documentation and test changes only, no behaviour, so neither is bumped.

No ADR or spec edit was needed, and none was made — no gate asked for one.


6. objectui rider

Per the ruling, objectui#4068's defaultValue-only seeding stays correct: after this lands the server honours option-defaults on omitted fields, so the console needs no second default contract. No objectui change accompanies this PR.


7. One thing found, not fixed here

defaultValue: '' on a single-valued select/radio is unstorable — validateRecord checks the value against optionValues(def.options) and '' is not among them, so the insert is refused as invalid_option. This is pre-existing and entirely unmoved by this PR; it surfaced only because a first draft of the precedence test used a select to pin the '' case. The test now pins that precedence on a free-form type, and says why in a comment. Flagged for the record rather than filed, since it is a design question about whether '' should be an authorable default on a closed value set, not a defect with a user hitting it today.


8. CI round 1 — two real failures from this change, both fixed here

The first CI round on this branch went red twice, and both were genuine blast radius, not flakes. Neither was visible to the three package suites run locally, which is the honest lesson: a behaviour-moving default reaches suites the changed packages do not name.

8.1 Test Core (2/3)examples/app-showcase/test/hook-body-persisted-writes.test.ts (#7258)

Two cases, both about showcase_inquiry.status — a census'd field (inquiry.object.ts:37, option new). Re-pinned to the new contract rather than worked around:

Case Was Now Why
THE #7254 WITNESS KEYS[email,message,name] KEYS[email,message,name,status] applyFieldDefaults runs at the TOP of the insert middleware, so engine-resolved defaults are on the record before the first beforeInsert body sees it — exactly as a defaultValue-declared field has always behaved. The priority-10 ordering the case is really about is untouched: source is still absent, because StampInquiryDefaultsHook (priority 50) has not run yet.
REVERSE: … the pins are not vacuous status == null and source == null source == null, plus status === 'new' asserted separately as the ENGINE's value This one needed thought rather than a value swap. The case exists to prove the hook's stamps are not produced by something else. status can no longer discriminate — the engine defaults it to the same 'new' the hook would write — so asserting 'new' there would make that half assert the same fact as the forward case, i.e. nothing. source is a plain Field.text with no options and no defaultValue, so 'web' still has exactly one producer, and the vacuity pin moves onto it instead of being softened.

Noted, not changed: StampInquiryDefaultsHook's status branch is now largely redundant with the engine (it still fires for an explicitly-supplied falsy value). It is a demo app's belt-and-braces hook and rewriting it is not this issue's scope.

8.2 TypeScript Type Check — the TEST_DEBT ratchet, not a type break

@objectstack/objectql's TEST_DEBT ledger records 355 raw tsc errors and the run reported 356 (+1). The ledger is a shrink-only ratchet, so +1 is red.

The cause was in this PR's new test file: 12 registry.registerObject(def) calls passing one argument where the signature is registerObject(def, ownerId, …). It was invisible to pnpm --filter @objectstack/objectql typecheck because the package's own tsconfig excludes its tests — that exclusion is precisely what TEST_DEBT measures, so the local green and the CI red were both correct about different things.

Fixed at the source by passing the owner id ('test.issue7246') rather than by raising the ledger. Re-measured with the gate's own method (tsconfig.test-equivalent, test exclusion lifted): 344 errors, 0 of them in this PR's files — at or below the recorded 355, so the ratchet is satisfied. The remaining 355→344 surplus is pre-existing on main and deliberately left alone; the gate states an improvement must not pay a bookkeeping toll to land.


Generated by Claude Code


Generated by Claude Code

…: true` is the field default (#7246)

`SelectOption.default` was authorable, spec-valid, and read by nothing on the
insert path: `applyFieldDefaults` resolved `f.defaultValue` and never looked at
`options`, so a create that omitted the field stored null instead of the marked
option. The key's only consumer anywhere was lint's `isNullableField`, which
concluded the column was always-valued — a build-breaking verdict resting on a
declaration the engine did not honour.

Executes the maintainer ruling on #7246 (ADR-0049 enforce leg):

- `applyFieldDefaults` falls back to the marked option when the field declares
  no `defaultValue`; `defaultValue` wins when both are declared.
- The fallback resolves inside the `defaultValue == null` arm, downstream of the
  token/envelope branches, so an option value is always a plain literal.
- `multiple: true` assembles an array; a single-valued field takes the first
  marked option; the canonical `default` spelling only, no `type` gate — the
  same shape lint reads, so the two agree by construction.
- No physical column DEFAULT for an option-default; the reasoning is recorded on
  `SqlDriver.applyDeclaredColumnDefault` and pinned by test.
- `isNullableField`'s prose now records what grounds it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 10, 2026 9:50am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/driver-sql, @objectstack/lint, @objectstack/objectql.

21 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx (via @objectstack/lint)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/driver-sql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/getting-started/glossary.mdx (via @objectstack/driver-sql)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/objectql)
  • content/docs/kernel/runtime-services/examples.mdx (via packages/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/driver-sql, @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/permissions/authorization.mdx (via @objectstack/lint)
  • content/docs/permissions/system-context.mdx (via packages/objectql)
  • content/docs/plugins/anatomy.mdx (via @objectstack/driver-sql)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/driver-sql, @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/driver-sql, @objectstack/objectql)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/driver-sql)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/driver-sql, packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)

2 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/driver-sql, @objectstack/objectql)
  • content/docs/releases/v17.mdx (via @objectstack/lint)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 10, 2026
CI round 1 went red twice on 61e8174, both genuine blast radius rather than
flakes, and neither visible to the three package suites run locally.

1. examples/app-showcase hook-body-persisted-writes (#7258) — `showcase_inquiry`
   declares `status: { value: 'new', default: true }` with no `defaultValue`, so
   the engine now fills it. `applyFieldDefaults` runs at the top of the insert
   middleware, hence `status` is in `ctx.input` before the first `beforeInsert`
   body — exactly as a `defaultValue`-declared field always behaved. The
   priority-10 ordering the WITNESS case is about is untouched.

   The REVERSE case needed more than a value swap: `status` can no longer
   witness "the hook ran", since the engine writes the same 'new' either way.
   `source` is a plain `Field.text` with no options and no `defaultValue`, so
   'web' still has exactly one producer — the vacuity pin moves onto it, and the
   engine-owned value is asserted separately for what it now is.

2. TypeScript Type Check — @objectstack/objectql's TEST_DEBT ratchet reported
   356 against a recorded 355. Cause was in this PR's new test file: 12
   `registerObject(def)` calls passing one argument where the signature is
   `registerObject(def, ownerId, ...)`. Invisible to the package's own
   `typecheck`, whose tsconfig excludes tests — which is precisely what
   TEST_DEBT measures. Fixed at the source by passing the owner id rather than
   by raising the ledger; re-measured with the gate's own method: 344 errors, 0
   in this PR's files.

Verified locally: app-showcase 16 files / 164 tests, app-crm 27, app-todo 105,
qa/downstream-contract 14, objectql option-default 14 — all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
@os-zhuang
os-zhuang marked this pull request as ready for review August 10, 2026 10:07
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 10, 2026
Merged via the queue into main with commit 1788e19 Aug 10, 2026
27 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-7246-enforce-select-option-default branch August 10, 2026 10:23
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] SelectOption.default is an authorable key that nothing on the insert path ever reads — the select idiom is inert

2 participants