feat(objectql): enforce the select idiom — the option marked default: true is the field default (#7246) - #7388
Conversation
…: 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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 3 package(s): 21 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
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
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.defaultwas authorable, spec-valid, and read by nothing on the insert path. Its only consumer anywhere in the repo was lint'sisNullableField, 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/mainbefore implementingorigin/mainapplyFieldDefaultsresolvesf.defaultValueand never readsoptionspackages/objectql/src/engine.ts— the loop gates onif (f.defaultValue == null) continue;, then branches Expression envelope →current_user→NOW()→ literal.optionsappears nowhere in the method, and the entry type was literally{ name; type?; defaultValue? }null, not the marked optionpackages/lint/src/validate-expressions.tsisNullableField()is the sole reader. The SQL DDL never readsoptionsat all — the only.optionshit indriver-sqlisAggregationFunction.options, a Zod enum's member list, unrelatedbuildNullableFieldIndex, whose findings are errorsPremise 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/andcontent/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 markeddefault: true.defaultValue→ insert behaviour moves (null becomes the marked option)examples/app-crm/src/objects/lead.object.ts:41status, optionnew+defaultValue: 'new'. They agree, so nothing observable changes; it is now resolved by thedefaultValuepath.multiple: truecarry a marked option (the corpus's two multi-selects —app-todotask.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:
examples/app-crm/src/objects/account.object.ts:24industrytechnologyexamples/app-crm/src/objects/activity.object.ts:24typecallexamples/app-crm/src/objects/activity.object.ts:34statusplannedexamples/app-crm/src/objects/lead.object.ts:57sourcewebexamples/app-crm/src/objects/opportunity.object.ts:29stageprospectingexamples/app-showcase/src/data/objects/account.object.ts:53industrytechnologyexamples/app-showcase/src/data/objects/account.object.ts:75statusprospectexamples/app-showcase/src/data/objects/account.object.ts:85sales_regionamerexamples/app-showcase/src/data/objects/cascading-select.object.ts:77tierstandardexamples/app-showcase/src/data/objects/contact.object.ts:53stagenewexamples/app-showcase/src/data/objects/expense-report.object.ts:47statusdraftexamples/app-showcase/src/data/objects/expense-report.object.ts:141categoryotherexamples/app-showcase/src/data/objects/expense-report.object.ts:156statussubmittedexamples/app-showcase/src/data/objects/field-zoo.object.ts:62f_selectlowexamples/app-showcase/src/data/objects/inquiry.object.ts:37statusnewexamples/app-showcase/src/data/objects/invoice.object.ts:104regionamerexamples/app-showcase/src/data/objects/invoice.object.ts:115statusdraftexamples/app-showcase/src/data/objects/preference.object.ts:22themelightexamples/app-showcase/src/data/objects/preference.object.ts:30default_landingmy_workexamples/app-showcase/src/data/objects/preference.object.ts:38email_digestdailyexamples/app-showcase/src/data/objects/project.object.ts:44statusplannedexamples/app-showcase/src/data/objects/project.object.ts:55healthgreenexamples/app-showcase/src/data/objects/semantic-zoo.object.ts:36statusdraftexamples/app-showcase/src/data/objects/semantic-zoo.object.ts:95statusredexamples/app-showcase/src/data/objects/task.object.ts:44statusbacklogexamples/app-showcase/src/data/objects/task.object.ts:58prioritymediumexamples/app-showcase/src/data/objects/team.object.ts:43engagementownerexamples/app-todo/src/objects/task.object.ts:29statusnot_startedexamples/app-todo/src/objects/task.object.ts:42prioritylowpackages/qa/downstream-contract/src/account.object.ts:14stageprospectEvery 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 markedDecision:
multiple: trueassembles 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.multipleis 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 amultiple: truefield 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.Field.selectbuilder normalizes optionvalueto lowercase and does not dedupe or refuse thedefaultflag, 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.(b) SQL DDL — no physical column DEFAULT for an option-default
Decision: the DDL does NOT emit one. The asymmetry with a
defaultValueliteral (which is emitted) is the decision, not an omission, and it is recorded onSqlDriver.applyDeclaredColumnDefaultso nobody "fixes" it by accident.The #4560 engine-token discipline does not forbid emitting here — an option's
valueis a plain literal, not a runtime token, so it could legally be emitted. The reasons not to:defaultValuebeats 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.multiple: truethe default is an array; emitting would need an invisible "except for multi-selects" carve-out.applyDeclaredColumnDefaultruns 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 onlydefault_mismatchproducer is the data: the SQL driver emits thecurrent_userframework token as a literal column DEFAULT, so the database itself writes a non-id into alookup('sys_user')#4560 runtime-token check, and there is no general declared-literal-vs-physical comparison anywhere — so nothing would ever report that divergence.current_userframework token as a literal column DEFAULT, so the database itself writes a non-id into alookup('sys_user')#4560 and data: theNOW()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
optionseither.(c) Presence semantics
The engine's own
dv == nulltest governs. The fallback fires only whendefaultValueis absent by that test, sodefaultValue: ''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 == nullarm, downstream of the token and envelope branches — after readingapplyFieldDefaults' real structure. This is structural, not a name check: an option value can therefore never be handed toisCurrentUserDefaultToken/isNowDefaultToken. An option spelledcurrent_useris a picklist entry and stores those twelve characters, not the acting user's id. Pinned by test.Two further decisions worth naming
optionsis a field-level key onFieldSchema, not gated ontype: 'select', andisNullableFieldreads 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.defaultspelling only.isDefault/selectedareSelectOptionSchemaauthoring 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.
The greens were predicted, not accidents — and three of them are an inverted direction worth stating plainly. The precedence pins (cases 5–7:
defaultValuewins,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 outrankingdefaultValue. 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.
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 -- ...andgit checkout HEAD -- ...throughout — nevergit stash, whose stack is shared across every worktree of the repo.5. Gates and tests
@objectstack/objectqltest@objectstack/driver-sqltest@objectstack/linttestcheck:engine-double-contractcheck:nul-bytescheck:error-code-casingcheck:route-envelopecheck:adr-0087-registrationcheck:spec-parsed-aliascheck:empty-changesetAll heavy runs were serialized under
flock /tmp/os-heavy-verify.lockwith 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) andautonumber-default-format-contract.mdare bothminor.lintanddriver-sqlcarry 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-valuedselect/radiois unstorable —validateRecordchecks the value againstoptionValues(def.options)and''is not among them, so the insert is refused asinvalid_option. This is pre-existing and entirely unmoved by this PR; it surfaced only because a first draft of the precedence test used aselectto 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, optionnew). Re-pinned to the new contract rather than worked around:THE #7254 WITNESSKEYS[email,message,name]KEYS[email,message,name,status]applyFieldDefaultsruns at the TOP of the insert middleware, so engine-resolved defaults are on the record before the firstbeforeInsertbody sees it — exactly as adefaultValue-declared field has always behaved. The priority-10 ordering the case is really about is untouched:sourceis still absent, becauseStampInquiryDefaultsHook(priority 50) has not run yet.REVERSE: … the pins are not vacuousstatus == nullandsource == nullsource == null, plusstatus === 'new'asserted separately as the ENGINE's valuestatuscan 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.sourceis a plainField.textwith no options and nodefaultValue, so'web'still has exactly one producer, and the vacuity pin moves onto it instead of being softened.Noted, not changed:
StampInquiryDefaultsHook'sstatusbranch 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— theTEST_DEBTratchet, not a type break@objectstack/objectql's TEST_DEBT ledger records 355 rawtscerrors 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 isregisterObject(def, ownerId, …). It was invisible topnpm --filter @objectstack/objectql typecheckbecause 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 onmainand deliberately left alone; the gate states an improvement must not pay a bookkeeping toll to land.Generated by Claude Code
Generated by Claude Code