Skip to content

fix(cli): generated migrations emit the character column driver-sql creates - #16298

Draft
os-litant wants to merge 4 commits into
mainfrom
claude/issue-16091-text-column-unbounded
Draft

fix(cli): generated migrations emit the character column driver-sql creates#16298
os-litant wants to merge 4 commits into
mainfrom
claude/issue-16091-text-column-unbounded

Conversation

@os-litant

@os-litant os-litant commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Fixes #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. The direction was ruled in advance on #15521 (comment 5557086667, which names this card): the generator follows the driver, the same principle #15040 applied to the id column 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:

x_text_uniq_max      {type:'text',     unique:true, maxLength:100}  driver varchar(100)  sql gen text  ts gen text
x_richtext_uniq_max  {type:'richtext', unique:true, maxLength:64}   driver varchar(64)   sql gen text  ts gen text

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. createColumn reads the object's INPUT. Its keyed argument is indexedKeyColumns(...).get(name); 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 sit 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. The sentence has been corrected in all four places it reached: three comments in generate.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.ts gains indexKeyColumns, a mirror of indexedKeyColumns: field-level unique at all three spellings it accepts (true / 'global' / 'organization'), object-level indexes[] whether 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. A keyed text-family column then takes keyableTextLength's answer: the declared bound verbatim up to MAX_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.

⚠️ The two rows that ALREADY agreed at text do not move, as required: x_text_uniq declares no bound, and x_text_uniq_big declares 1000, past the key-part ceiling. Both stay text in 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.13

Three schemas, one per producer, columns read back out of information_schema.columns.

                              before (83edbc55f3e)   after (9cc1a76df2c)
character columns compared    378 total probed       378 total probed
divergent, character          47                     15
  of which the keyed class    32                      0
  of which FILE_REFERENCE     15                     15   (#15041, untouched)
reviewer's 4 keyed probes     2 divergent             0 divergent
the original 9 rows           0 divergent             0 divergent

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 exactly file / 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:

organization_id text(50) + code text(30) unique:true          driver varchar(50)  sql varchar(50)  ts varchar(50)
   same, code unique:'global'                                 driver text         sql text         ts text
   same, tenancy:{enabled:false}                              driver text         sql text         ts text
org text(40) + code unique:true, tenancy:{tenantField:'org'}   driver varchar(40)  sql varchar(40)  ts varchar(40)
body text(100) with indexes:[{fields:['body']}]               driver varchar(100) sql varchar(100) ts varchar(100)
   its sibling column, not listed in any index                driver text         sql text         ts text

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: mutating radio: 'TEXT' or secret: 'TEXT' in FIELD_TYPE_SQL_MAP passed 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 classes driver-sql seeds JSON_COLUMN_TYPES from (MULTI_OPTION_TYPES, STRUCTURED_JSON_TYPES, FILE_REFERENCE_TYPES), imported and never listed — and VARCHAR(255) is asserted on the rest, along with the fact that neither maxLength, nor unique, nor a declared index moves it. Both mutations now redden, measured below.

R2 — a false reason in a code comment. It said MAX_VARCHAR_CHARS is transcribed "because 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 and the REASON moves, not the transcription: the constant is protected static on SqlDriver, 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 (oclif import()s every command module on every invocation; schema-migrate.lazy-driver-import.test.ts enforces 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 why indexKeyColumns mirrors 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-sql through its own initObjects, os generate migration --format sql through db.raw of the emitted DDL, and os generate migration (typescript, the default format) by importing the emitted module and calling up(db). Columns were read back out of information_schema.columns; every 300-character probe is a real INSERT.

The card's row reproduces exactly:

producer   f_text        300-char write
driver     text          ACCEPTED — read back at length 300
sql gen    varchar(255)  REFUSED  — value too long for type character varying(255)
ts gen     varchar(255)  REFUSED  — same

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_MAP entry and every createColumn arm that produces a character type, and driving 26 probe columns through all three producers, nine diverged:

                driver          sql gen         ts gen          cause
f_text          text            varchar(255)    varchar(255)    the card's row
f_text_max      text            varchar(255)    varchar(255)    maxLength does NOT size an UNKEYED text column
f_email_max     varchar(400)    varchar(255)    varchar(255)    maxLength was never read
f_url           varchar(255)    varchar(2048)   varchar(255)    invented width
f_url_max       varchar(1024)   varchar(2048)   varchar(255)    all three disagreed
f_url_huge      text            varchar(2048)   varchar(255)    past the ceiling means TEXT
f_phone         varchar(255)    varchar(50)     varchar(255)    invented width
f_phone_max     varchar(20)     varchar(50)     varchar(255)    all three disagreed
f_color         varchar(255)    varchar(7)      varchar(255)    invented width

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 at 9cc1a76df2c and all nine agree.

Both directions are real failures, and the wide one is the quieter:

  • NARROW is the card's own shape, one type over. A 300-character value into a maxLength: 400 email was accepted by the driver's table and refused by both generated ones.
  • WIDE fails in reverse: a 300-character url was ACCEPTED by the sql format's varchar(2048) table and REFUSED by the driver's own varchar(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

createColumn sorts every character column into three arms that answer the declaration differently. That is the whole content of this change:

  1. Text familykeyable === null ? table.text(name) : table.string(name, keyable) over keyable = keyed ? this.keyableTextLength(field) : null. The branch is on KEYED, and keyed is the OBJECT'S DECLARATION, not this generator's output: indexedKeyColumns reads field.unique and indexes[]. UNKEYED the column is unbounded, maxLength declared or not; KEYED it is varchar(maxLength) up to 768 and unbounded above.
  2. String family (email / url / phone / password) — declared === null ? table.text(name) : table.string(name, declared) over declaredVarcharLength(field), which reads maxLength unconditionally, 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.
  3. Catch-alltable.string(name) at knex's default width, reading neither maxLength nor unique, because the stored value is an option code, an opaque ref or another row's id rather than the declared string. Only color diverged.

A note that contradicts a reasonable expectation, so it is stated loudly and pinned: a declared maxLength on an UNKEYED text field does not size its column, and must not. Unkeyed, the bound is enforced at the write seam — schema-drift.ts says so in as many words: "A TEXT column refuses nothing a maxLength allows … 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 emits varchar(n) and the generator must match. generate-string-family-width.pin.test.ts pins 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 whole maxLength half 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 by createColumn's own arms and by the driver's own DEFAULT_STRING_VARCHAR_CHARS / MAX_VARCHAR_CHARS / MAX_KEYABLE_VARCHAR_CHARS constants, with nothing left to judge — and each is evidenced by the driven table above. Repairing text alone would have shipped a fix that leaves the identical hard failure standing one type over.

What is deliberately NOT touched

file / image / avatar / video / audio stay at VARCHAR(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 — and generate-field-type-vocabulary.pin.test.ts already records it as a divergence rather than coverage. Nothing here rules on it.

No MySQL or SQLite claim is made or widened. --format sql declares 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:

The pin, and proving it can fail

generate-string-family-width.pin.test.ts asserts agreement with the driver, read off the driver's own source rather than transcribed. Arm MEMBERSHIP is read out of createColumn'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 whole keyed chain is asserted link by link in sql-driver.ts and schema-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.ts at 9cc1a76df2c. 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 HEAD empty AND blob hash equal to the HEAD blob), under an EXIT INT TERM trap with absolute paths.

leg  mutation                                            predicted  observed
M13  radio: 'VARCHAR(255)' -> 'TEXT'                     RED        1 failed | 68 passed   catch-all case
M14  secret: 'VARCHAR(255)' -> 'TEXT'                    RED        1 failed | 68 passed   catch-all case
M15  keyed ? keyableTextChars(maxLength) : null -> null  RED        6 failed | 63 passed
M16  drop 'global' from the unique vocabulary            RED        2 failed | 67 passed
M17  stop reading the object's indexes[]                 RED        1 failed | 68 passed
M18  stop resolving the tenant key part                  RED        1 failed | 68 passed

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's generate.ts restored 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.ts red at its anti-vacuity assertion, generate-multiple-json-column.pin.test.ts red at its single_text control, and generate-builtin-id-column.pin.test.ts green 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.ts asserted sqlColumn('autonumber') === sqlColumn('text'). Both were VARCHAR(255), which made text a usable stand-in for "the driver's default string column"; it is not one any more. It now compares against lookup, whose table.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.ts used a text field 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.ts asserted ordering by searching for table.string('title'). Once title became table.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.

  • The four pin files: Test Files 4 passed (4) / Tests 69 passed (69) (61 before this round).
  • pnpm --filter @objectstack/cli run typecheck exit 0, including check:test-typecheckOK — @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 --listFiles lists both src/commands/generate.ts and src/commands/generate-string-family-width.pin.test.ts.
  • ESLint, narrowed and the narrowing proved: eslint --no-inline-config --format json over 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 single eslint.config.mjs never enables type-aware linting for any file (no parserOptions.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.
  • Gate family re-derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack against 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-adoption and the 6209-file corpus sweep, the four changeset gates plus check: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, and check:i18n / check:i18n-coverage / check:i18n-walk-parity / check:i18n-stale-fill.
  • Three of those first reported exit 3 / exit 1 with PREREQUISITE NOT MET and 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/main exits 0 against origin/main at 6c546ab9d0b — a clean merge. The gate derivation ran on a tree behind that origin/main, so the workflow churn across the gap was read directly: the only gate families origin/main adds are check:release-index-currency-sync and release-verify-npm.mjs --self-test, both release-tooling self-tests reached by no path of this diff.
  • CI's own conclusion is not waited for here, per the dispatch contract; the gate jobs are the seat's to read.

Clause ②: yes, graded from this diff, and round 2 does not change it — the generators now consult field.unique and indexes[] on top of maxLength, all keys they have never read, so a declaration that produced one column yesterday produces another today. All four new symbols in generate.ts are module-private.

Governed surfaces (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md): none touched. packages/drivers/**: none touched. The changeset stays minor and now states the keyed half.

Filed, not fixed

Draft, auto-merge unarmed.

…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
@github-actions github-actions Bot added the size/l label Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 17 documentable anchor(s).

26 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json b9a14dd64f0e579adf94d683eda729991f6935e0.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

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

Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json b9a14dd64f0e579adf94d683eda729991f6935e0packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6738e5d5309e256d302c3b33f2097304d4401def — the merge of head 11d0e8d46f0c8d1a505493e0e1ea8371bbd2f22c into base b9a14dd64f0e579adf94d683eda729991f6935e0, which is what actions/checkout gives a pull_request run. Not the PR head.

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

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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

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

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 6, 2026
…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

Copy link
Copy Markdown
Collaborator Author

Head moved: eab52a72470 to 83edbc55f3e. One word, in the changeset only — "@objectstack/cli": patch becomes minor. The generator, the pins, the PR body and every label are untouched.

Posting this as a comment rather than editing the body, because the body states that its 61-family union was measured at eab52a72470 and that is still exactly where it was measured. This records the delta instead of rewriting that sentence.

Why the level moved

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 maintainer's 2026-09-04 ruling (decision batch #35, on #15294). This branch is yes and moves packages/cli/src/**, so the two declarations contradicted each other.

Driven at both heads, with the form that can read the axis

⚠️ The plain --base origin/main form cannot see this axis. At this same head it reports:

ℹ️ LEVEL AXIS: NOT MEASURED — no clause-② declaration was readable for this PR, so whether
`patch` fits the surface was not judged. This is neither a pass nor a failure (#4690).
   · no `pull_request` payload was available to read a declaration from

Read with --event carrying the label set as it will be once the carrier is hung:

  • patch, at eab52a72470 — exit 1: ⛔ This PR declares clause-② YES and grades a package it grew patch. naming @objectstack/cli: patch ← this PR moves @objectstack/cli's packages/*/src/**
  • minor, at 83edbc55f3e — exit 0:
✓ This diff introduces no `major` bump.
✓ LEVEL AXIS: this PR declares clause-② `yes`, and no package whose `packages/*/src/**` it moves is graded `patch`.
   · carrier: `needs:contract-review` IS on this PR

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 taken

Both --event runs report the same second line:

· declaration line: a near miss, not a declaration — Clause ②: **yes**, graded from this diff. …

CLAUSE2_KEY_LINE requires the machine spelling Clause-②: with a hyphen; the body writes Clause ②: with a space, so readClause2Line classifies it a near miss and the body carries no readable declaration. Consequences, measured rather than assumed: without the carrier the axis reads NOT MEASURED (control run with today's four labels confirms it), and with the carrier it reads yes from the carrier alone — so hanging the carrier is what makes this green, and it does. The claim comment on the card carries the correct machine spelling, so check-clause2-carriers reads the card side normally.

⛔ 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 83edbc55f3e

The gate family is byte-identical to the one derived at the previous head — Reconciliation — 57 famil(ies), and the --commands harvest diffs clean against the earlier one. Every family the harvest matches through .changeset, which is the only path this commit moves, was re-run there and is green in its own words:

  • ✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).
  • ✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).
  • ✓ .changeset/config.json "fixed" group is in sync with 69 public workspace packages.
  • ✓ check:published-files — 69 publishable package(s) ... none narrows its resolvable surface against the merge base without a minor changeset naming the deep paths that stop resolving.
  • plus check:changeset-gate-self-tests, check:objectui-changeset, release-rehearsal-clone --self-test and both --self-test forms above, all exit 0.

check-changeset-no-major --self-test passes 157 assertions including "the LEVEL axis on #16044's two real heads", so the axis this relies on is itself pinned in both directions.

Still a draft, auto-merge unarmed.


Generated by Claude Code

…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

Copy link
Copy Markdown
Collaborator Author

Round-2 attribution, recorded here because the PR body's footer block did not survive the edit.

The body above was rewritten by a PATCH at 2026-09-06T14:10:34Z. It was submitted ending with a blank line, a --- rule and the italic _Generated by [Claude Code](https://claude.ai/code)_ line; reading the body back immediately afterwards, the whole block from the rule line onward is absent — not downgraded from the session-URL form to the bare form, removed. Recorded rather than re-posted in a loop.

Durable attribution for this round lives where the platform cannot rewrite it — the branch's own commit trailers:

Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N

Head at the time of writing: 9cc1a76df2c.


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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants