fix(cli): generated migrations emit the character column driver-sql creates - #16298
fix(cli): generated migrations emit the character column driver-sql creates#16298os-litant wants to merge 4 commits into
Conversation
…reates (#16091) Both migration generators capped a `text` field at VARCHAR(255) while `driver-sql` creates an unbounded `text` column for it, so a 300-character value the platform stores was refused by every generated table with `value too long for type character varying(255)`. #15521's ruling names this card and settles its direction -- the generator follows the driver, as #15040 already did for the `id` column in this same file. Driven on a private PostgreSQL 16.13 cluster, all three producers run from one object and their columns read back out of `information_schema.columns`. The sweep found nine divergent columns of 26 probed, not one: text driver text gen varchar(255) both formats text+max driver text gen varchar(255) maxLength must NOT size it email+max driver varchar(400) gen varchar(255) maxLength was never read url driver varchar(255) sql varchar(2048) invented width phone driver varchar(255) sql varchar(50) invented width color driver varchar(255) sql varchar(7) invented width All of them now follow `createColumn`'s three arms. The text family is unbounded, because that arm branches on KEYED and a generated migration emits no index; its declared bound is enforced at the write seam, not by the column. The string family takes `declaredVarcharLength`'s answer -- the declaration verbatim in both directions, knex's 255 without one, and TEXT above the varchar ceiling rather than a clamp to it. The catch-all keeps the default width and ignores a declaration, because its stored value is an option code or another row's id rather than the declared string. Driven again afterwards: 0 of 26 columns diverge, and the 300-character write is accepted in all three tables exactly where the platform accepts it and refused in all three exactly where the platform refuses it. `generate-string-family-width.pin.test.ts` asserts that agreement against the driver's own source -- arm membership read from `createColumn`'s case labels, widths read from its own constants -- so a driver that moves fails there instead of leaving the generators quietly wrong. Three existing pin files move with it: two used `text`'s old VARCHAR(255) as a stand-in for the driver's default string column, and one asserted column ordering by searching for a `table.string` call that is now a `table.text` call. Scope is PostgreSQL, the only dialect `--format sql` claims (#15521). The FILE_REFERENCE_TYPES divergence stays recorded and unresolved (#15041). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
📓 Docs Drift CheckThis PR changes 1 package(s): 26 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6738e5d5309e256d302c3b33f2097304d4401def && git checkout 6738e5d5309e256d302c3b33f2097304d4401def
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b9a14dd64f0e579adf94d683eda729991f6935e0 11d0e8d46f0c8d1a505493e0e1ea8371bbd2f22c && git checkout -B drift-repro b9a14dd64f0e579adf94d683eda729991f6935e0 && git merge --no-ff 11d0e8d46f0c8d1a505493e0e1ea8371bbd2f22c
node scripts/docs-audit/affected-docs.mjs --json b9a14dd64f0e579adf94d683eda729991f6935e0
|
…d clause-② requires `Check Changeset`'s LEVEL AXIS (#16055) refuses a PR that declares clause-② YES while grading a package whose `packages/*/src/**` it moves as `patch`. The rule it mechanizes is the maintainer's 2026-09-04 ruling (decision batch #35, on #15294), written out under "WHICH LEVEL" in that step: a purely additive widening of a published package's public surface takes AT LEAST `minor`, and the commit type may raise a bump but never lower it below what the act requires. This branch declares clause-② `yes` and moves `packages/cli/src/**`, so the level and the declaration contradicted each other. Only the level moves here -- the generators, the pins and the measurements are untouched.⚠️ The axis is invisible to the plain `--base origin/main` form of the gate, which reports `LEVEL AXIS: NOT MEASURED` and is neither a pass nor a failure. It is judged only from a `pull_request` event payload, off the `needs:contract-review` carrier or a machine-spelled `Clause-②:` line, so `--event` is the only form that can confirm this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
|
Head moved: Posting this as a comment rather than editing the body, because the body states that its 61-family union was measured at Why the level moved
Driven at both heads, with the form that can read the axis
Read with
The gate reads the committed diff, not the working tree, so the level had to be committed before it could be confirmed. One reading worth flagging, no action takenBoth
⛔ Left alone rather than corrected, because the body is not mine to touch here. It is a one-character fix if it is wanted. Re-measured at
|
…e driver does
CORRECTING THE RECORD. This branch's first commit, the new pin's docblock and
three comments in `generate.ts` all said: "the text family branches on KEYED,
and a generated migration emits no index, so no generated column is ever
keyed." That sentence describes this GENERATOR'S OUTPUT. `createColumn` reads
the object's INPUT. Its `keyed` argument is `indexedKeyColumns(...).get(name)`,
and `indexedKeyColumns` composes `uniqueIndexesFromFields` -- which keys a
column on `field.unique`, a key every `FieldSchema` carries -- with the
object's declared `indexes[]`. Both are DECLARATIONS, both are in the config
these generators already read, and neither has anything to do with what a
migration emits. The generator could have read `unique`; it simply did not.
So a keyed text-family column IS sized from its declaration, at
`keyableTextLength`'s width: the declared `maxLength` verbatim up to
MAX_KEYABLE_VARCHAR_CHARS (768, the widest one utf8mb4 key part holds), and
unbounded above that ceiling or with no usable declaration. Driven on live
PostgreSQL 16.13 against the pre-change tree, one 300-character write into
`{ type: 'text', unique: true, maxLength: 100 }`:
driver varchar(100) REFUSED -- 22001 character varying(100)
sql gen text ACCEPTED -- read back at length 300
ts gen text ACCEPTED -- read back at length 300
The wide direction, which this branch's own body calls the quieter of the two,
inside the family it claimed to have closed. Re-driven after the change, all
three producers REFUSE it, and 0 of 32 keyed character columns diverge.
WHAT MOVES
* `generate.ts` gains `indexKeyColumns`, a mirror of the driver's own
composition -- field-level `unique` at all three spellings, object-level
`indexes[]` unique or not, and the ADR-0120 D3 tenant key part, whose
resolution (`tenancy.enabled`, `tenancy.tenantField`, an
`organization_id` column) is computable from the object alone and so is
mirrored rather than skipped. It also gains `keyableTextChars` and the
transcribed 768 ceiling, kept deliberately separate from
`declaredVarchar`: the two answer different questions of the same key.
* The false sentence is corrected in all four places it reached.
* The new pin gains the keyed arm: the driver-source chain at every link,
the arm membership held equal to `createColumn`'s case labels, the width
sweep at both outcomes, the three unique spellings against the words the
spec rejects, the object-level index half, and the tenant-column half --
each of the last two confirmed against the live cluster before pinning.
TWO RIDERS FROM THE SAME REVIEW
* The pin's catch-all case skipped any member whose plain answer had already
drifted, so it measured that the catch-all takes the driver's default
width only where that already held. Mutating `radio` or `secret` to
'TEXT' passed all 61 tests across all four pin files. The character half
of the catch-all is now DERIVED from the three spec classes `driver-sql`
seeds `JSON_COLUMN_TYPES` from -- imported, never listed -- and
`VARCHAR(255)` is asserted on the rest. Both mutations now redden.
* A comment gave a false reason for transcribing `MAX_VARCHAR_CHARS`:
"`packages/cli` does not depend on the driver at runtime". It does --
`@objectstack/driver-sql` is in this package's `dependencies` at
`workspace:^`. The transcription is still necessary, for two other
reasons: the constant is `protected static`, and #5726 forbids a CLI
production module any static value import of a driver package. The reason
moves; the transcription does not.
The changeset stays `minor` and states the keyed half.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
|
Round-2 attribution, recorded here because the PR body's footer block did not survive the edit. The body above was rewritten by a Durable attribution for this round lives where the platform cannot rewrite it — the branch's own commit trailers: Head at the time of writing: Generated by Claude Code |
`generate.ts` mirrors four things `driver-sql` owns. Two of them were already
falsifiable from the driver: `MAX_KEYABLE_VARCHAR_CHARS` is compared against the
constant's own declaration and `TEXT_FAMILY_TYPES` against `createColumn`'s own
case labels, and a driver-side mutation of either reddens the pin. The other two
mirror driver BODIES, which a source reader cannot see move — mutating
`keyableTextLength` to clamp instead of answering null, and each of five
mutations across `schema-drift`, `computeTenantField` and spec's
`isUniqueDeclared`, left all 69 pins green.
Both are now recomputed from `driver-sql` itself and compared:
- the key set, from the driver's own exported `uniqueIndexesFromFields` and
`normalizeDeclaredIndex` with the tenant column from a `SqlDriver` subclass
that publishes `computeTenantField`, over a swept corpus of 1,224 objects
(every combination of a field-level `unique` spelling, an `indexes[]` entry,
a `tenancy` declaration and a column shape), against the key set read back
out of what both generators emit;
- both widths, from the driver's own `keyableTextLength` and
`declaredVarcharLength` through the same subclass, over 37 declarations
including the coerced and rejected spellings.
A test file is not a CLI production module: #5726 governs
`packages/cli/src/**` production sources, and the gate enforcing it excludes
`*.test.ts` by construction. The package already declares `@objectstack/driver-sql`
and the specifier is already in `KNOWN_UNALIASED_TEST_IMPORTS`, so neither the
dependency graph nor that shrink-only ledger moves.
The differential found one branch of `indexKeyColumns` disagreeing with the
driver, and this fixes it. `normalizeDeclaredIndex` filters an entry's
`nullSafeColumns` against its listed columns, but that filter narrows only
`nullSafeColumns` — its `columns` stay the listed ones in every branch of the
arm. Reading the filter as if it decided the KEY PARTS made
`{ fields: ['f'], unique: 'organization', nullSafeColumns: ['zzz'] }` key
`{organization_id, f}` here against the driver's `{f}`: a column bounded in a
generated migration that the platform leaves unbounded. The condition is now the
driver's own — a non-empty array, nothing more — and the comment claiming the
mirrored branch kept this set from being a strict superset of the driver's is
replaced, since that branch was the one making it exactly that.
`isUniqueDeclared` and `isTenancyDisabled` are imported from
`@objectstack/spec/data` rather than transcribed. Spec is not a driver package,
so #5726 never reached them, and `isTenancyDisabled` is ADR-0066's single
judgment for the registry, the engine and every driver. The transcriptions that
remain now state their real warrant: `MAX_VARCHAR_CHARS`,
`MAX_KEYABLE_VARCHAR_CHARS`, `keyableTextLength`, `declaredVarcharLength` and
`computeTenantField` are `protected` and reach no exported surface, while
`isOrganizationScopedUnique` is exported and is spelled here only because these
generators are synchronous and #5726 leaves a production module `await import()`
alone for a driver package.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
Fixes #16091
Both migration generators capped a
textfield atVARCHAR(255)whiledriver-sqlcreates an unboundedtextcolumn for it, so a 300-character value the platform stores was refused by every generated table. The direction was ruled in advance on #15521 (comment5557086667, which names this card): the generator follows the driver, the same principle #15040 applied to theidcolumn in this same file. This makes both generators emit the character column the driver actually creates — for the whole character-column family, not only the row the card named.packages/drivers/**is the AUTHORITY here and is read, never edited.Round 2 — the KEYED half, and a correction to the record
The contract review drove 14 probes the first sweep never carried, and two of them still diverged at
83edbc55f3e:Re-driven at that head, a 300-character write into
x_text_uniq_max: driver REFUSED (22001 character varying(100)), sql gen ACCEPTED at length 300, ts gen ACCEPTED at length 300. The WIDE direction — the one this body calls the quieter of the two — still live, inside the family this body claimed to have closed.The sentence that missed it was wrong, and it was wrong in a specific way. It read: the branch is on KEYED, and neither generator emits an index, so no generated column is ever keyed. That describes this GENERATOR'S OUTPUT.
createColumnreads the object's INPUT. Itskeyedargument isindexedKeyColumns(...).get(name);indexedKeyColumnscomposesuniqueIndexesFromFields— which keys a column onfield.unique, a key everyFieldSchemacarries — with the object's declaredindexes[]. Both are DECLARATIONS, both sit in the config these generators already read, and neither has anything to do with what a migration emits. The generator could have readunique; it simply did not. The sentence has been corrected in all four places it reached: three comments ingenerate.ts, the new pin's docblock, and this section of the body. The new commit message states the correction in its own words, because the queue composes the squashed body from the branch's commit messages.The remedy is the preferred one — the generators now follow the driver here too.
generate.tsgainsindexKeyColumns, a mirror ofindexedKeyColumns: field-leveluniqueat all three spellings it accepts (true/'global'/'organization'), object-levelindexes[]whether unique or not, and the ADR-0120 D3 tenant key part, whose resolution (tenancy.enabled,tenancy.tenantField, anorganization_idcolumn) is computable from the object alone and so is mirrored rather than skipped. A keyed text-family column then takeskeyableTextLength's answer: the declared bound verbatim up toMAX_KEYABLE_VARCHAR_CHARS(768), and unbounded above that ceiling or with no usable declaration — never a clamp TO the ceiling, the same rule the string family follows one arm over.textdo not move, as required:x_text_uniqdeclares no bound, andx_text_uniq_bigdeclares 1000, past the key-part ceiling. Both staytextin all three producers.The mirror is transcribed rather than imported because #5726 forbids a CLI production module any static value import of an
@objectstack/driver-*package — see the rider below.Re-driven at
9cc1a76df2c, on live PostgreSQL 16.13Three schemas, one per producer, columns read back out of
information_schema.columns.The 32 are the whole text family (8 members read off
createColumn's own case labels) across the four keyed declaration shapes that reach a bound a key part can hold. The 15 that remain are exactlyfile/image/avatar/video/audio— #15041's recorded, deliberately unresolved divergence — and they were divergent before this branch as well.The 300-character write into
x_text_uniq_max, re-driven after the change: REFUSED by all three, exactly where the platform refuses it.Tenant-scoped and object-level shapes were driven against the live driver BEFORE being pinned, never inferred:
Two riders from the same review
R1 — the new pin's catch-all case was vacuous for a drifted member. It read
if (plain.sql !== VARCHAR(255)) continue;, which skips any member whose plain answer has ALREADY regressed — so the case measured its own claim only where that claim already held. Measured: mutatingradio: 'TEXT'orsecret: 'TEXT'inFIELD_TYPE_SQL_MAPpassed all 61 tests across all four pin files. The character half of the catch-all is now DERIVED — the catch-all members minus the three spec classesdriver-sqlseedsJSON_COLUMN_TYPESfrom (MULTI_OPTION_TYPES,STRUCTURED_JSON_TYPES,FILE_REFERENCE_TYPES), imported and never listed — andVARCHAR(255)is asserted on the rest, along with the fact that neithermaxLength, norunique, nor a declared index moves it. Both mutations now redden, measured below.R2 — a false reason in a code comment. It said
MAX_VARCHAR_CHARSis transcribed "becausepackages/clidoes not depend on the driver at runtime". It does:@objectstack/driver-sqlis in this package'sdependenciesatworkspace:^. The transcription is still necessary and the REASON moves, not the transcription: the constant isprotected staticonSqlDriver, so it is not on the package's exported surface at all, and #5726 independently forbids a CLI production module any static value import of a driver package (oclifimport()s every command module on every invocation;schema-migrate.lazy-driver-import.test.tsenforces it). Both reasons are now stated, and the false one is named as false so the next reader does not "simplify" the transcription away. This is also whyindexKeyColumnsmirrors the driver's composition rather than calling it.Re-driven, not quoted
The card asked for its readings to be re-run rather than relayed, and they were, in both rounds. A private PostgreSQL 16.13 cluster was stood up in this container (the same version #15521 used), and all three producers were driven into it from ONE object:
driver-sqlthrough its owninitObjects,os generate migration --format sqlthroughdb.rawof the emitted DDL, andos generate migration(typescript, the default format) by importing the emitted module and callingup(db). Columns were read back out ofinformation_schema.columns; every 300-character probe is a realINSERT.The card's row reproduces exactly:
The class is nine rows wide, not one
The seat asked for the string family to be enumerated rather than the single row repaired. Sweeping every
FIELD_TYPE_SQL_MAPentry and everycreateColumnarm that produces a character type, and driving 26 probe columns through all three producers, nine diverged:The other 17 already agreed and are untouched: the seven remaining text-family members, plus
email/password/select/radio/secret/tree/lookup/master_detail/user/autonumber. All nine were re-driven by name at9cc1a76df2cand all nine agree.Both directions are real failures, and the wide one is the quieter:
maxLength: 400email was accepted by the driver's table and refused by both generated ones.varchar(2048)table and REFUSED by the driver's ownvarchar(255). The scaffold invited a value the platform will not keep, and nothing anywhere names that. The keyed row above is the same failure, found one round later.The three arms, and where the declaration reaches
createColumnsorts every character column into three arms that answer the declaration differently. That is the whole content of this change:keyable === null ? table.text(name) : table.string(name, keyable)overkeyable = keyed ? this.keyableTextLength(field) : null. The branch is on KEYED, andkeyedis the OBJECT'S DECLARATION, not this generator's output:indexedKeyColumnsreadsfield.uniqueandindexes[]. UNKEYED the column is unbounded,maxLengthdeclared or not; KEYED it isvarchar(maxLength)up to 768 and unbounded above.email/url/phone/password) —declared === null ? table.text(name) : table.string(name, declared)overdeclaredVarcharLength(field), which readsmaxLengthunconditionally, with no keyed requirement, and has three outcomes: the declaration verbatim, knex's 255 without one, and TEXT above the varchar ceiling — never a clamp to the ceiling, since a clamp reinstates the very defect.table.string(name)at knex's default width, reading neithermaxLengthnorunique, because the stored value is an option code, an opaque ref or another row's id rather than the declared string. Onlycolordiverged.⭐ A note that contradicts a reasonable expectation, so it is stated loudly and pinned: a declared
maxLengthon an UNKEYEDtextfield does not size its column, and must not. Unkeyed, the bound is enforced at the write seam —schema-drift.tssays so in as many words: "A TEXT column refuses nothing amaxLengthallows … the bound is enforced at the write seam." Sizing it there would look like honouring the author and would be this card's own defect pointed the other way. KEYED is the opposite answer, for the opposite reason: MySQL refuses a TEXT column in a key without a prefix length, so the driver emitsvarchar(n)and the generator must match.generate-string-family-width.pin.test.tspins both, and pins that they really are different answers to the same declaration.Repaired in place beyond the card's own row, declared rather than slipped in
url,phone,color, the wholemaxLengthhalf and the keyed half are not the row the card named. They are repaired here because they are the same defect class asked of the same authority, and the seat's dispatch asked for the class rather than the row. Each is mechanical — the correct shape is fixed bycreateColumn's own arms and by the driver's ownDEFAULT_STRING_VARCHAR_CHARS/MAX_VARCHAR_CHARS/MAX_KEYABLE_VARCHAR_CHARSconstants, with nothing left to judge — and each is evidenced by the driven table above. Repairingtextalone would have shipped a fix that leaves the identical hard failure standing one type over.What is deliberately NOT touched
file/image/avatar/video/audiostay atVARCHAR(2048)against a driver that gives them a JSON column. That is #15041's recorded divergence — two ADR-0104 positions rather than a wrong value — andgenerate-field-type-vocabulary.pin.test.tsalready records it as a divergence rather than coverage. Nothing here rules on it.No MySQL or SQLite claim is made or widened.
--format sqldeclares itself PostgreSQL-only (#15521) and this change stays inside that scope.What the probe set still cannot reach
A sweep is evidence of presence, never of absence — this round exists because 26 probes missed a reachable declaration shape. Stated so the next reader does not have to re-derive it:
FieldTypemembers at all. A field with notypekey, and atypestring that is not a member — the unvalidated authoring door. Both diverge, measured, and are filed as [finding] driver-sql and both migration generators default an absent or unknown fieldtypeto DIFFERENT families —stringversustext, so the unvalidated authoring door produces two different columns #16319 rather than repaired here.real, generatorsnumeric, andratingisrealagainstinteger#16318); the JSON families are [finding] packages/cli generate.ts: both migration generators ignoremultiple: true, so a multi-valued field gets a scalar column whileos generate typesgives it an array type #14829 / [finding] FILE_REFERENCE_TYPES disagree about their column:driver-sqlputs file/image/avatar/video/audio inJSON_COLUMN_TYPES,packages/cligenerate.ts gives themVARCHAR(2048)— and neither side is obviously the one that should move #15041's question and are pinned elsewhere.uniqueconstraints ([finding]os generate migrationemits no declared index at all — a generated table carries none of the object'suniqueconstraints, while driver-sql creates them #16317).--format sqlclaims.The pin, and proving it can fail
generate-string-family-width.pin.test.tsasserts agreement with the driver, read off the driver's own source rather than transcribed. Arm MEMBERSHIP is read out ofcreateColumn's own case labels (so a type joining or leaving an arm changes what is measured with nobody editing the test), all three widths are read off the driver's own constants, the wholekeyedchain is asserted link by link insql-driver.tsandschema-drift.ts, and every extractor carries a non-vacuity control.Round 2's falsification conditions were written down and their direction predicted before each ran, then applied one at a time to
generate.tsat9cc1a76df2c. Every leg proved the mutation had landed on disk by counting the removed and the injected text — never by an edit tool's exit code — and proved the restore by observed state (git diff HEADempty AND blob hash equal to the HEAD blob), under anEXIT INT TERMtrap with absolute paths.M13 and M14 are R1's proof: those two mutations passed all 61 tests before this round.
Round 1's ablation stands as recorded:
origin/main'sgenerate.tsrestored over the fix with the tests left in place, the new pin's measurement cases red and its control case GREEN,generate-field-type-vocabulary.pin.test.tsred at its anti-vacuity assertion,generate-multiple-json-column.pin.test.tsred at itssingle_textcontrol, andgenerate-builtin-id-column.pin.test.tsgreen deliberately — the edit there made an ordering assertion column-method agnostic, true on both trees.Three pin files moved, and why each had to
Each of these went red on the fix and is repaired toward the driver rather than around it:
generate-field-type-vocabulary.pin.test.tsassertedsqlColumn('autonumber') === sqlColumn('text'). Both wereVARCHAR(255), which madetexta usable stand-in for "the driver's default string column"; it is not one any more. It now compares againstlookup, whosetable.string(name)arm is asserted from the driver in the same breath, plus an anti-vacuity assertion that the comparator is genuinely a different answer from the text family's.generate-multiple-json-column.pin.test.tsused atextfield as its scalar-versus-JSON control. TEXT is still scalar, so the control keeps its job at its new value, and it now also asserts that the scalar answer differs from the flagged one.generate-builtin-id-column.pin.test.tsasserted ordering by searching fortable.string('title'). Oncetitlebecametable.text, that search returned-1— and "less than -1" reads as a passing comparison until you notice what it is less than. It now matches on the field NAME, and asserts the column was found at all.Verification
Every round-2 reading below was taken at
9cc1a76df2c, on a clean tree, and every exit code was captured by redirect-then-capture — never after a pipe.Test Files 4 passed (4) / Tests 69 passed (69)(61 before this round).pnpm --filter @objectstack/cli run typecheckexit 0, includingcheck:test-typecheck—OK — @objectstack/cli's test layer compiles under packages/cli/tsconfig.test.json. Coverage of the two edited files was measured rather than assumed:tsc --noEmit --listFileslists bothsrc/commands/generate.tsandsrc/commands/generate-string-family-width.pin.test.ts.eslint --no-inline-config --format jsonover the two edited TypeScript files reports 2 file entries, 0 errors, 0 warnings, 0 suppressed. The narrowing is safe to read as a measurement because this repo's singleeslint.config.mjsnever enables type-aware linting for any file (noparserOptions.project, no typed rules — stated and measured in that file's own header), so no edit here can move a verdict on a file this run did not read.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackagainst the current diff: 124 commands across 54 matched families. The subset actually implicated by these paths was run and is green in its own words —check:nul-bytes(no raw ASCII control bytes, 7986 files),check-keyed-text-bounds(148 keyed text-family columns judged, 148 bounded) plus its self-test,check:cross-package-test-inputs(27 package(s) read outside themselves, all declared),check:test-source-alias,check:comment-mask-adoptionand the 6209-file corpus sweep, the four changeset gates pluscheck:changeset-gate-self-tests,check:doc-authoring,check:undeclared-dep-imports,check:closing-keyword-parity,check:error-code-casing,check:type-source-resolution,check:published-files, andcheck:i18n/check:i18n-coverage/check:i18n-walk-parity/check:i18n-stale-fill.PREREQUISITE NOT METand were read as NOT MEASURED rather than as passes: the i18n trio needs the built CLI. The build closure they name was run and all three were then converted into real readings —check-i18n-bundles: OK (9 package(s) — all bundles in sync),check-i18n-coverage: OK (13 config(s), 621 baselined untranslated string(s), none new),check-i18n-walk-parity: 11 declared group(s), 8 walked, 3 exempted.git merge-tree --write-tree HEAD origin/mainexits 0 againstorigin/mainat6c546ab9d0b— a clean merge. The gate derivation ran on a tree behind thatorigin/main, so the workflow churn across the gap was read directly: the only gate familiesorigin/mainadds arecheck:release-index-currency-syncandrelease-verify-npm.mjs --self-test, both release-tooling self-tests reached by no path of this diff.Clause ②: yes, graded from this diff, and round 2 does not change it — the generators now consult
field.uniqueandindexes[]on top ofmaxLength, all keys they have never read, so a declaration that produced one column yesterday produces another today. All four new symbols ingenerate.tsare module-private.Governed surfaces (
docs/adr/**,.claude/**,skills/**,AGENTS.md,CLAUDE.md): none touched.packages/drivers/**: none touched. The changeset staysminorand now states the keyed half.Filed, not fixed
required— which ADR-0113 moved the driver OFF — never readstorage.notNull, and dropdefaultValueentirely: 4 of 6 probed columns diverge on live Postgres #16294 — both generators bind an authored column'sNOT NULLtorequired, which ADR-0113 explicitly moved the driver OFF; neither readsstorage.notNull; neither emits the columnDEFAULTthe driver produces fromdefaultValue. 4 of 6 probed columns diverge, in both directions.os generate migrationemits no declared index at all — a generated table carries none of the object'suniqueconstraints, while driver-sql creates them #16317 — neither generator emits a declared index at all, so a generated table carries none of the object'suniqueconstraints whiledriver-sqlcreates them. Sharper after this change, since the key set is now computed ingenerate.tsand still not emitted.real, generatorsnumeric, andratingisrealagainstinteger#16318 — the NUMERIC family diverges in both formats: driverrealagainstnumeric, andratingrealagainstinteger. Seven of seven, on the plainest declaration. Not a mechanical repair —realis lossy forcurrency, so which side moves is a decision.typeto DIFFERENT families —stringversustext, so the unvalidated authoring door produces two different columns #16319 —driver-sqland both generators default an absent or unknowntypeto different families (stringversustext), so the unvalidated authoring door produces two different columns.Draft, auto-merge unarmed.