Repository navigation
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
…omments drop their deleted tracker numbers (stage 11) (objectstack-ai#21578) Part of objectstack-ai#20234 Clause-②: no Stage 11 of the dead-citation sweep: the hand-written parts of `packages/spec/src/migrations/registry.ts`, under ruling C+D (record `5749154545` on objectstack-ai#19123). At base, those parts carried 13 sites citing deleted tracker numbers: 2 in source comments and 11 in step rationale text that authors read. A step `rationale` is author-shown: `os migrate meta` prints it per hop, and step 17's is projected into `docs/protocol-upgrade-guide.md`. Text only. No step, fragment id, fragment order, generated entry, conversion or code token moves. - **Form C, the 2 comments.** Each dead number now cites the commit that decided it (verified against the commit's own text), and the sentence says what that commit did. - **Form D, the 11 rationale sites.** They sit in 9 rationale units: step 17's `mongodb` paragraph and 8 step-18 fragments (a fragment is "one retirement's sentences"). Each of those 9 units now carries no number at all. The 11 dead numbers go, and so do the 11 live numbers in the same units, because form D removes every number from author-visible text. Each sentence states the cited decision in words, read from its decision record, not its title. - **Regenerated, never hand-edited:** `docs/protocol-upgrade-guide.md` line 68. `check:generated --fix` proved it the only stale artifact of 15. `gen:migration-registry` has nothing to do, because no generated region mirrors a rationale. - One `@objectstack/spec` **patch** changeset. ## What changes Line numbers are at base `e909aa0a23`. "Dead" means `GET issues/N` answers 404, with no redirect followed. | line | number(s) | decision, as read, and its record | now reads | |---|---|---|---| | 344 | objectstack-ai#6345 (dead), objectstack-ai#4410 (live) | `mongo` → `mongodb` became the canonical driver id under one shared driver vocabulary (commit e2798fa). objectstack-ai#4410: `datasource.config` is parsed against the declared driver's contract, and `mongo` and `mongodb` both map to the mongo contract (commit cdf4d9a) | "renamed to `mongodb`. The two spellings have both been accepted since `datasource.config` was first parsed against its driver's own contract" | | 5180 | objectstack-ai#14676 (dead) | retire `connector.errorMapping`, ADR-0049 (commit 13c48c2) | the number dropped; the fragment already states the decision | | 5601–5608 | objectstack-ai#12868 (dead); objectui#6263 ×2, objectstack-ai#7246, PR objectstack-ai#7388 (live) | narrow per-option `default` out of the form-view vocabulary only, disposition 甲 (commit c459da6, whose diff added this fragment). objectui#6263: the console's form controls never read the key, and the ruling closed that card. objectstack-ai#7246 / objectstack-ai#7388: maintainer ruling of 2026-08-10, "enforce" on the object-field face (`applyFieldDefaults`) | "maintainer ruling 2026-08-28 on the console form renderer's analysis"; "enforced there by a maintainer ruling of 2026-08-10"; "measured against the console's form controls, none of which reads the key" | | 5703–5704 | objectstack-ai#10329 (dead), objectstack-ai#4509 (live) | retire the `lookup` steering params (commit 15d58db). objectstack-ai#4509: 17.0.0 retired the keys the lint could never warn about, the mapping trio among them (commit 5966c2a) | "the sub-walk half of the 17.0.0 mapping cleanup that retired `extractQuery` / `errorPolicy` / `batchSize`" | | 5718, 5721 | objectstack-ai#8336 (live), objectstack-ai#8495 (dead) | refuse `${…}` in memory `persistence.path` / `persistence.key` at publish (commit 4bfe1a5). objectstack-ai#8336: the 2026-08-13 ruling refusing placeholders in datasource connection config at publish (the protocol-17 entry `datasource-config-placeholder-refused`) | "extends the publish-time refusal of unresolved placeholders, which protocol 17 applied to datasource connection config, to the memory driver's …"; "(objectstack-ai#8495)" dropped | | 5732–5733 | objectstack-ai#13135 (dead), objectstack-ai#12057 (live) | retire the paper customization protocol with its full coupling set; the maintainer adopted retirement 2026-08-29 (commit 9e0ba21, whose diff carries the number) | "(ADR-0049 enforce-or-remove, maintainer ruling 2026-08-29)" | | 5963, 5966, 5973, 5975 | objectstack-ai#10724, objectstack-ai#10627, objectstack-ai#10726 (dead); objectstack-ai#11169 (live) | retire nine dead `contributes` members; a controlled three-repo census found one non-test read (commit be21955). objectstack-ai#10726: `routes` later retired too, ruled Option B (commit bc56e18, whose diff carries the number). objectstack-ai#11169: retire `kinds[].globs`, maintainer ruling 2026-08-24 (commit be21955) | "A census of all three repos, with controls, measured …"; "`routes` was left to a ruling of its own, which retired it as well (the `plugin-manifest-contributes-routes-retired` entry)" | | 6020 | objectstack-ai#10485 (dead) | retire stack `themes` and `ThemeSchema`, ruled B (commit 35ad101) | the number dropped | | 6049, 6052, 6058 | objectstack-ai#10926 (dead); objectstack-ai#9249 ×2 (live) | retire the `submitLabel` copy key, Option A (commit d173125). objectstack-ai#9249: `element:form` retired whole (the `element-form-retired` fragment, which renders earlier) | "retired whole above"; "nothing read it once `element:form` was retired" | | 6301 (comment) | objectstack-ai#8495 (dead) | as above (commit 4bfe1a5) | "(commit 4bfe1a5, PR objectstack-ai#8666: `${…}` refused at publish in `persistence.path` / `persistence.key`)" | | 24935 (comment) | objectstack-ai#6239 (dead) | the 2026-08-08 sweep retired the viewId-addressed `ViewProtocol` as whole defs (commit f549a0d, whose subject names the number) | "(commit f549a0d, D3 `view-management-protocol-retired`)" | Where a commit message does not name its number, the anchor was confirmed from the files that commit added: c459da6, 9e0ba21 and bc56e18 each carry their number in their own diff. Both sha anchors added to comments are unambiguous: `rev-parse --disambiguate` count 1, and `GET commits/SHA` returns the full sha (a 4-character control returns 422). `merge-base --is-ancestor` answers 0 for all 11 anchors. The checkout is shallow, with a boundary of 2026-06-28. Every anchor is later than that, and exit 0 proves ancestry on its own. ## What stays, and why (counts left on objectstack-ai#20234) - **Live numbers in comments: 46 sites (plus 1 cross-repo).** The rule applies to dead citations in comments, so these stay. - **Live numbers in author-visible rationale text outside the 9 units: 192 sites** (175 in-repo, 17 cross-repo; 142 distinct in-repo numbers). Step 17 holds 116 of them and step 18 holds 76. Form D would remove them too, but they are not dead citations, and they lie outside this stage's claimed surface. Note that objectstack-ai#20749 (the runtime-string burn-down) counts migration-registry text as its share, yet both of its stage claims (`5965785560`, `5966674486`) exclude `migrations/registry.ts` as "objectstack-ai#20234's stage 11". So no stage currently covers them. `migrations.test.ts` pins one of them, the live objectstack-ai#4722 in step 17, so whichever stage takes them moves that pin. This is reported as the open question. - The 6 dead numbers from census pointer `5923089344` are outside this stage, as dispatched. ## Verification **Deadness.** A TypeScript 6.0.3 walker runs the citation gate's own `extractCitations` over two projections of the file (comment-only and string-only, line structure kept). It classes each site by kind and by region; the generated regions are the 6 `os-generated` pairs. At base, the hand-written parts hold 262 sites: 48 in comments (1 cross-repo) and 214 in rationale strings (19 cross-repo), over 177 distinct in-repo numbers. A REST probe of all 177 (`issues/N`, no redirect followed; lit control objectstack-ai#20234 and dead control objectstack-ai#8710 at the start, after every 50 and at the end): 165 answer 200, 12 answer 404, and 0 answer 301. Controls behaved 5/5 lit and 5/5 dead. The 12 numbers sit on exactly the 13 sites stage 10 named (`5902947213`). At head, the 162 distinct numbers all answer 200 (controls 5/5 and 5/5), and 0 dead sites remain. Site-set difference: 24 removed (13 dead and 11 live, all inside the 9 units) and 1 added (objectstack-ai#8666, live, the PR link beside commit 4bfe1a5 in a comment, which form C allows). The 911 sites in generated regions do not change. **Text only.** A second instrument over TypeScript 6.0.3 evaluates the step-17 `rationale` and every step-18 fragment `text` (72 owners). It masks each of those initializers to one token that names its owner and cuts every comment, JSDoc included. The base and head leaf-token streams are IDENTICAL: 37,526 tokens, sha256 prefix `647bc09580ac619e` on both sides. Evaluated values changed for exactly 9 owners, the units listed above, and each word diff is the citation and its replacement words only. Controls mutate the head in memory: a comment insertion stays IDENTICAL, and each of these DIFFERS: a fragment `order` change, a fragment `id` change, an unmasked string edit, a conversion-id string edit, and an appended declaration (6/6). **Regeneration.** Before regeneration, `check:migration-registry` and `check:spec-changes` exit 0, and `check:upgrade-guide` exits 1. `check:generated --fix` regenerated only the guide. Its word diff on line 68 shows `mongodb` (objectstack-ai#6345). becoming `mongodb`. and objectstack-ai#4410 becoming the decision in words. After the `origin/main` merge (`6dd99b82c3`, through `os-regen-merge.sh`, which touched none of these paths), a rebuild plus `check:generated` reports "All 15 generated artifacts are up to date". No regeneration commit was owed. **Pins and quotes.** No test asserts a changed phrase. `content/docs/**` and `skills/**` quote none of them. The other hits for the old spellings are independent comments in other files (live numbers), release-owned CHANGELOGs and one test title, all left alone. **Tests and gates** (at head `64bbd3693e`; all builds and tests through `scripts/pm/os-verify-lock.sh`, each VERDICT command-exit 0): - `pnpm --filter @objectstack/spec build`, and a full `turbo run build --concurrency=2` over `./packages/*` and `./packages/*/*` (71/71). - spec local project: Test Files 606 passed, Tests 17952 passed, 1 todo. - spec repo project, 47 of its 51 files by name: 47 passed, 750 tests, including `scripts/step18-rationale-merge.test.ts`. - `pnpm --filter @objectstack/spec typecheck`: `check:test-typecheck` OK. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack`: 85 families derived, 85 run, all exit 0, reconciled with `--ran` ("0 NOT-MEASURED, 0 UNRUN"). That includes `check:doc-authoring` (self-test and run), `check:issue-citations`, `check:nul-bytes`, `check:generated`, `check:migration-registry`, `check:upgrade-guide` and the changeset gates. Three of them first exited 3 (prerequisite: unbuilt dist) and were rerun green after the full build. - Lint, as a proven narrowing: `eslint --no-inline-config --format json` over the one touched `.ts` file reports 1 file, 0 errors, 0 warnings, and `isPathIgnored` is false. `eslint.config.mjs:327-328` states that type-aware linting is never enabled, so a text edit cannot move an untouched file's verdict. The repo-wide `pnpm lint` is CI's run. - NOT MEASURED, declared to CI as in stages 8 to 10: `scripts/build-schemas-check-mode.test.ts` (it overran the foreground cap on its own in stage 10; it parses this file's source, whose code tokens the text-only proof shows unchanged), plus `def-key-collisions` and the two `publish-smoke-*` files (temp-tree rebuild or server boot). **Changeset.** `packages/spec` `files[]` ships `dist`. In the built dist, the new phrases appear in 2 files (`dist/migrations/index.js`, `.mjs`), the old spellings in 0, and an unchanged control phrase in the same 2. Hence `patch`. ## Acceptance notes - **Open, for the seat:** whether the 192 live author-visible numbers become a stage, and on which card (objectstack-ai#20234, or objectstack-ai#20749 whose stages currently exclude this file). They are not filed. - **Gate coverage boundary (carrier: none; noted, not filed):** the citation gate reads comment prose only, so these 11 dead rationale sites were never in its census. Stage 9 recorded the same boundary for conversion summaries. --- _Generated by [Claude Code](https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
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