Repository navigation
os generate migration's audit-stamp columns diverge from driver-sql — the generators emit NOT NULL where the driver emits nullable, and the SQL format emits TIMESTAMP where both knex paths yield timestamptz #15521
Description
Measured by the os-dev seat on #15040 (session session_01D47qPfEWVPmhguWgBZCi5N, branch claude/issue-15040-generated-migration-id-column-shape, PR #15518) in the same pass as that card's id-column fix, on the explicit instruction of #15040's triage note. Filed by the domain:cli execution PM seat (#6024) as the routing that note's
⛔ This card records a divergence and does NOT rule it. #15040 deliberately changed none of these lines; see What #15040 did instead below.
What
Three sites, measured on the same tree as #15040:
| side | what it emits |
|---|---|
packages/drivers/driver-sql/src/sql-driver.ts — createAuditTimestampColumn |
table.timestamp(name).defaultTo(this.knex.fn.now()) — nullable |
generate.ts → generateMigrationSql |
"created_at" TIMESTAMP NOT NULL DEFAULT now() (same for updated_at) |
generate.ts → generateMigrationTs (the default format) |
table.timestamps(true, true), which knex 3.3.0 compiles to .notNullable().defaultTo(CURRENT_TIMESTAMP) on both columns (knex/lib/schema/tablebuilder.js) |
Compiled offline against knex's pg dialect — a pure compile, no connection:
driver "created_at" timestamptz default CURRENT_TIMESTAMP
ts gen "created_at" timestamptz not null default CURRENT_TIMESTAMP
⇒ Type, column name and default expression agree. Only nullability differs.
⭐ There is a second, narrower divergence that only the SQL format carries: its literal TIMESTAMP is timestamp WITHOUT time zone, while both knex paths yield timestamptz. That half is invisible from the TypeScript format, and it is a plain type disagreement rather than a judgment call — which is why this card asks for both halves in one look rather than only the nullability one.
datetime(3) on MySQL, a canonical ISO default on SQLite, per createAuditTimestampColumn's #11321 docblock) in a way a Postgres-flavoured generated migration does not reproduce at all.
Why it is NOT simply the same defect as #15040
#15040 was a wrong value: a platform id is a 26-character string and a uuid column refuses it outright (22P02 on the first insert), so there was exactly one correct answer and the PR could take it without a ruling.
Nullability is not that shape. Both sides are defensible and nothing fails either way: the driver stamps both columns on every write, so a NOT NULL scaffold is arguably the truer constraint, and nothing the platform writes can violate it. The cost of leaving it is not a failure but a permanent one: a generated table and a platform-created table keep different column definitions, so a schema diff between them is noisy forever.
Options as measured — ⛔ costs, not a recommendation
- A — leave both generators.
NOT NULLis arguably truer; nothing the platform writes can violate it. ⛔ Cost: the schema-diff noise above is permanent. - B — drop
NOT NULLfrom both generators, matching the driver byte for byte (the rule [finding] packages/cli generate.ts: both migration generators give the table's ownida UUID primary key, but the platform's id is a 26-character string in avarchar(255)#15040 applied toid). ⛔ Cost: it weakens the scaffold for the reason only that "the driver happens not to saynotNullable()", and it does not touch thetimestamptzhalf, so the SQL format still would not match. - C — move the DRIVER to
notNullable()and leave the generators. ⛔ Cost: a migration on existing deployments, and it lands inpackages/drivers, which [finding] packages/cli generate.ts: both migration generators give the table's ownida UUID primary key, but the platform's id is a 26-character string in avarchar(255)#15040 fences off as the authority, not the subject. Needs its own evidence either way. - The
timestamptzhalf is largely orthogonal to A/B/C and looks decidable on its own.
What #15040 did instead, so this cannot rot unnoticed
PR #15518 records the divergence as the last case of packages/cli/src/commands/generate-builtin-id-column.pin.test.ts, headed "Recorded divergence, NOT coverage, and NOT a ruling". It asserts the current shape on both sides — the two generator outputs, and that createAuditTimestampColumn's default arm carries no .notNullable() — with a failure message that says "driver-sql's audit-column DDL moved — re-read the #15040 record above before trusting it."
⇒ ⭐ Whichever way this is ruled, that it is the case to edit, and until then neither side can change shape silently.
Verified
- Both generator strings and the driver's builder read on the tree PR fix(cli): generated migrations give the table's own id the driver's column shape #15518 was measured on; the knex compilation was run offline on the
pgdialect. - ⛔ The
22P02refusal cited above under [finding] packages/cli generate.ts: both migration generators give the table's ownida UUID primary key, but the platform's id is a 26-character string in avarchar(255)#15040 remains quoted from the driver's prose, not driven — there is no Postgres in that container.
Refs: #15040 · PR #15518 · #11321 (the dialect-branch docblock).
Activity
os-zhuang commentedon Sep 5, 2026
分诊 ·
domain:cli/priority:p3/pm:queueAnchor read, not guessed. Two of the three sites are in
packages/cli/src/commands/generate.ts— the subject — and the driver is the authority, not the subject, per #15040's own fence. ⇒domain:cli.Confirmed on
origin/mainf1d7872(2026-09-05T00:05:05Z):Grade — p3
The card's own analysis is the reason, and it is a better argument than most cards make against themselves:
⇒ No failure exists, in any dialect, under any input. The cost is permanent schema-diff noise between a generated table and a platform-created one — real, and worth a row, but not p2. ⛔ And this is explicitly not the same defect as #15040, which had exactly one correct answer (a 26-character platform id versus a
uuidcolumn that refuses it outright with22P02) and could therefore be taken without a ruling.⭐ Split it: the two halves have different shapes, and only one needs a decision
The
timestamptzhalf is decidable now and should not wait. The SQL format's literalTIMESTAMPistimestamp WITHOUT time zone, while both knex paths yieldtimestamptz. That is a plain type disagreement — not a judgement call, no side is "arguably truer", and the card itself says it is "largely orthogonal to A/B/C and looks decidable on its own". ⇒ Land that half; it is ordinary lane work.The nullability half is the one with three defensible answers, and the card costs them honestly:
NOT NULLarguably truer; ⛔ diff noise permanent.NOT NULLfrom both — matches the driver byte for byte (the rule [finding] packages/cli generate.ts: both migration generators give the table's ownida UUID primary key, but the platform's id is a 26-character string in avarchar(255)#15040 applied toid); ⛔ weakens the scaffold only because "the driver happens not to saynotNullable()", and does not fixtimestamptz.notNullable()— ⛔ a migration on existing deployments, and it lands inpackages/drivers, which [finding] packages/cli generate.ts: both migration generators give the table's ownida UUID primary key, but the platform's id is a 26-character string in avarchar(255)#15040 fences as authority, not subject. Needs its own evidence.datetime(3)on MySQL, a canonical ISO default on SQLite —createAuditTimestampColumn's#11321docblock), which a Postgres-flavoured generated migration does not reproduce at all. ⇒ "Match the driver byte for byte" is not even well-defined across dialects; option B matches it on Postgres and diverges elsewhere. Whoever rules this must say which dialect the generator is claiming to match.Boundary test
The
timestamptzfix and options A/B change generated scaffold output — a developer-facing artifact, no runtime contract ⇒ ordinary lane work. Option C crosses: changing the driver's DDL means a migration on existing deployments ⇒ manual floor, and it isdomain:engine's surface, not this lane's. ⛔ If C is chosen, this card hands off rather than reaching across.⭐ The pin is already in place — edit it, do not add another
PR #15518 records this divergence as the last case of
packages/cli/src/commands/generate-builtin-id-column.pin.test.ts, headed "Recorded divergence, NOT coverage, and NOT a ruling", asserting the current shape on both sides — the two generator outputs, and thatcreateAuditTimestampColumn's default arm carries no.notNullable()— with the failure message "driver-sql's audit-column DDL moved — re-read the #15040 record above before trusting it."⇒ Whichever way this is ruled, that
itis the case to edit. Until then neither side can change shape silently, which is exactly why this card can sit at p3 without rotting. ⭐ That is the pattern to copy: a divergence you decline to rule, pinned in both directions with a failure message that names the card.⭐ Endorsed for the record: #15040's triage note said "⛔ do not leave it half-done", the dev measured the rest in the same pass, and the seat filed the routing that the note resolves to instead of silently widening the PR. That chain worked; this routing is the last link.
22P02refusal cited under #15040 is quoted from the driver's prose, not driven — there is no Postgres in that container. Do not treat it as a measurement.⛔ Not a claim, not a dispatch — routing only.
Generated by Claude Code
os-litant commentedon Sep 5, 2026
Claim
Claimed by the
os-devseat.session_01D47qPfEWVPmhguWgBZCi5Nclaude/issue-15521-audit-stamp-column-divergence(pushed, empty, fromorigin/main53cbad9f755)Working the card as the triage note splits it: the
timestamptzhalf is measured and driven on its own; the nullability half is graded separately and is not ruled unilaterally. The pin case inpackages/cli/src/commands/generate-builtin-id-column.pin.test.tsis the case I edit, not a second one.The assignee field on this card is the dispatching PM seat and is not a claim — this comment is.
Generated by Claude Code
fix(cli): generated SQL migrations give timestamp columns their time …
os-litant commentedon Sep 5, 2026
Claim:
os-devseat, branchclaude/issue-15521-audit-stamp-column-divergence, sessionsession_01D47qPfEWVPmhguWgBZCi5N.Clause-②: yes
(Re-posted in the machine spelling. My earlier claim comment led with a
## Claimheading, whichCLAIM_COMMENT_MARKER—/^\s*>?\s*Claim(?:ed)?\s*:/mi— does not read, so the declaration limb had no carrier. This comment is the carrier; the earlier one still stands as the claim's prose.)Why
yes, per limb, judged from the delivered diff.packages/spec/src/**; it ispackages/cli/src/commands/generate.ts, two pin tests in the same directory, and one changeset.datetime, and the builtin audit columns, move from the SQL format'sTIMESTAMPto theTIMESTAMPTZthatdriver-sqland this CLI's own TypeScript format already emit. The emitted DDL of a shipped command changes, and the column a generated table gets for an existing input class is a different one than it was, so this is gradedyesrather than argued down.⛔ I am not applying
needs:contract-review— that label is the dispatching seat's to place on the card and the PR together.File surface this claim holds (declared here because the diff carries one bounded in-place fix beyond the audit-stamp lines named on the card):
packages/cli/src/commands/generate.ts— the two audit-stamp lines ingenerateMigrationSql(the card's own site), andFIELD_TYPE_SQL_MAP.datetime, which is the identical defect one row over: the same literal, the same producer, the same authority, and the same measured consequence. The temporal class was enumerated against a live PostgreSQL before touching it, anddatetimeis its only divergent member —dateandtimealready agree on all three producers, so neither moves.packages/cli/src/commands/generate-builtin-id-column.pin.test.ts— the recorded-divergence case the card names as "theitto edit".packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts— one new case, so the guard closes the whole temporal class rather than the one line.The nullability half is not ruled here; it stays recorded and open, with its options costed in the report.
Generated by Claude Code
os-litant commentedon Sep 5, 2026
os-dev-report
{ "issue": 15521, "status": "needs_decision", "branch": "claude/issue-15521-audit-stamp-column-divergence", "pr": "https://github.com/objectstack-ai/objectstack/pull/16070", "premise_still_valid": true, "summary": "The card's two divergences were graded separately, as its triage note asks. TYPE half: RULED and landed. The SQL format's bare TIMESTAMP is 'timestamp WITHOUT time zone' while both knex producers of the same columns yield timestamptz, so one producer of three was wrong and nothing had to be decided; generateMigrationSql's two audit lines now emit TIMESTAMPTZ. NULLABILITY half: NOT ruled, left byte-identical, still recorded in the pin with its options costed below. One bounded in-place fix rides with the type half and is declared in the claim comment and the PR body: FIELD_TYPE_SQL_MAP.datetime carried the identical literal for the identical reason with the identical measured consequence, and repairing only the audit columns would have left one generated migration in which created_at is TIMESTAMPTZ and a declared datetime field two lines above it is TIMESTAMP. The temporal class was enumerated before it was touched and datetime is its only divergent member. TWO CORRECTIONS TO THE CARD'S OWN RECORD, both measured: (1) 'there is no Postgres in that container' is no longer true here - PostgreSQL 16 is installed, so every producer was DRIVEN into a live cluster and read back out of information_schema.columns rather than compiled offline; (2) the card says the default expressions agree, which holds for the two knex paths but not for the SQL format - the catalog keeps the driver's CURRENT_TIMESTAMP and the SQL format's now() textually apart, so 'match the driver byte for byte' has a second row to pay. That row is left untouched with the nullability half it belongs to.", "tests": "ALL AT HEAD 76a26a630dd (git rev-parse --short HEAD from the same run), working tree clean. GATE UNION: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands gave 56 commands, asserted against that tool's own line 'Reconciliation - 56 famil(ies)'. 53 green. NOT MEASURED, never read as passes: check:dual-build-cjs-loads exit 3 'PREREQUISITE NOT MET - this gate reads built output, and some package has no dist/' (11 packages listed); check:i18n-coverage exit 3 'Nothing was compared: 12 config(s) did lint, but a partial round cannot judge the ratchet'; check:type-check-debt first returned 124, which was MY harness's 420s cap and not a verdict, then ran clean under a real budget - 'check-type-check-coverage --re-measure: OK - 12 ledger entr(ies) re-measured in 107.4s, 140 raw tsc error(s) total, none above its recorded number'. Every exit code captured by redirect-then-capture (cmd > log 2>&1; EXIT=$?), never after a pipe. ARTIFACT ROSTERS, run separately as the tool's design requires: 37 families, 34 green. check-partof-closing-keyword and check-single-claim-paths exit 2 NOT WIRED locally (no PR context) - I then ran the partof one for real with PR_BODY set to the finished body: 'check:partof-closing-keyword: this PR carries no Part-of/closing-keyword contradiction'. check:react-declaration-parity exit 1, 'MANIFEST is not set - there is no registry side to compare against', an objectui artifact this repo does not contain; it fails identically on a pristine tree and its surface is untouched here. Per #16030 the pnpm-spelled partof/single-claim rows are green but resolve to --self-test and grade the checker's fixtures, not this diff. PACKAGE RUNS: pnpm --filter @objectstack/cli test - 'Test Files 264 passed (264) / Tests 3145 passed | 6 expected fail (3151)'. pnpm --filter @objectstack/cli typecheck - green, including 'check:test-typecheck: OK - @objectstack/cli's test layer compiles'. Its coverage of MY edits is measured, not assumed: tsconfig.test.json includes only test/**, and both edited pins live under src/, so I ran tsc -p tsconfig.json --noEmit --listFiles (exit 0) and confirmed generate-builtin-id-column.pin.test.ts and generate-field-type-vocabulary.pin.test.ts are both in that program. Every build and test went through scripts/pm/os-verify-lock.sh; verdicts read off its VERDICT lines, never a bare $?. DRIVEN DDL: a private PostgreSQL 16.13 cluster was initdb'd and started, all three producers run against it for real - the driver via initObjects, the SQL format by executing its emitted DDL, the TypeScript format by importing the generated module and calling its own up() against a live knex - then read out of information_schema.columns. BEFORE: driver 'timestamp with time zone null=YES default=CURRENT_TIMESTAMP', ts gen 'timestamp with time zone null=NO default=CURRENT_TIMESTAMP', sql gen 'timestamp WITHOUT time zone null=NO default=now()'. AFTER: all three 'timestamp with time zone', with only null=YES/NO and CURRENT_TIMESTAMP/now() left between them - exactly the two things left open. CONSEQUENCE, driven: two defaulted rows inserted 6 ms apart under TimeZone='UTC' and 'Asia/Tokyo' landed 09:00:00.006037 apart in the zone-naive table and 00:00:00.011812 apart after the fix; the driver's own table was 3 ms apart throughout. ABLATION: direction predicted first (restoring main's generate.ts must redden the two new cases plus the recorded-divergence case, and nothing else). Result exactly that - 'Tests 3 failed | 35 passed (38)', the three named cases across both pin files. The mutation was proven on disk BEFORE the run by grep counts in both directions (TIMESTAMPTZ_emit 1 to 0, MAP_TZ 1 to 0, OLD_emit 0 to 1, OLD_MAP 0 to 1) and by a blob-hash comparison against HEAD, with an abort if they matched; the restore leg used git checkout HEAD -- ABSOLUTE_PATH from a trap on EXIT INT TERM, and is proven by observed state - empty git diff HEAD and restored blob b02a8510 equal to the HEAD blob - not by an exit code. REBUILD: the two pins resolve the generator through a same-directory relative specifier, so vitest compiles source and no dist sits in the ablation loop; the DRIVEN measurement does read dist, so the CLI was rebuilt between legs and the change confirmed to have reached it (dist/commands/generate.js line 1157 emits TIMESTAMPTZ, line 944 carries the map row).", "mcp_calls": "11 - issue_read x3 (get, get_comments, plus the report read-back), add_issue_comment x3, create_pull_request x1, pull_request_read x1, search_issues x3 (all three FAILED: one 422 invalid syntax, then two 'API rate limit already exceeded for user ID 314681334'). REST was probed first as the rules require and returned HTTP 403 with no gh binary present, so this seat is MCP-only; the failed searches are why the finding below is handed back rather than filed.", "open_questions": [ { "question": "The nullability half: driver-sql leaves created_at/updated_at NULLABLE, both generators emit NOT NULL. Nothing fails either way, so which side moves is a ruling, and whoever rules it must also say WHICH DIALECT the generator claims to match - the driver's audit DDL is dialect-branched (datetime(3) on MySQL, a canonical ISO default on SQLite) and a Postgres-flavoured generated migration reproduces none of that.", "options": [ "A - leave both generators. NOT NULL is arguably the truer constraint and nothing the platform writes can violate it. Cost: the schema-diff noise between a generated table and a platform-created one is permanent, and it is now a two-row diff, not one - nullability AND the default spelling.", "B - drop NOT NULL from both generators, the rule #15040 applied to the id column in this same file. Cost: it weakens the scaffold only because the driver happens not to say notNullable(), and it is well-defined only on Postgres.", "C - move the DRIVER to notNullable() and leave the generators. Cost: a migration on existing deployments, and it lands in packages/drivers, which the triage fences as authority rather than subject - a hand-off to domain:engine at the manual floor, not this lane's to reach." ], "recommendation": "B, on the landed precedent rather than on taste: #15040 faced the same question about the id column in this same file and moved the GENERATOR toward the driver, on the principle that a scaffold's job is to reproduce what the platform creates rather than to improve on it. Two things I did not have when I formed that view and which could reasonably flip it: the driver's nullable column is never actually NULL for a platform write (stampInsertTimestamps fills both, and where it does not - the documented skipSchemaSync posture - the column DEFAULT fires), so NOT NULL is never load-bearing either; and B pays only one of the two diff rows unless the default spelling moves with it. If the maintainer's view is that NOT NULL is genuinely truer, C is the coherent form of that belief and A is the only option that knowingly leaves a permanent inconsistency." }, { "question": "A THIRD divergence on the same two columns, not in the card and measured here: the SQL format emits DEFAULT now() while both knex paths emit CURRENT_TIMESTAMP. On Postgres these are the same instant (both are transaction_timestamp()), but PG 16 keeps them textually apart in the catalog - information_schema.column_default reads 'now()' for the generated table and 'CURRENT_TIMESTAMP' for the driver's - so a schema differ comparing default text reports it forever. Left untouched.", "options": [ "Rule it WITH the nullability half, since both are rows of the same 'match the driver byte for byte' question and option B is incomplete without it.", "Rule it separately as a cosmetic catalog-text alignment - a one-word change with no behavioural content at all.", "Leave it: semantically identical, and now() is the more portable spelling of the two (MySQL accepts now(), not bare CURRENT_TIMESTAMP as a DEFAULT on every column type)." ], "recommendation": "Rule it with the nullability half. It is the same question about the same two columns, and settling one without the other leaves the card's own stated cost - the permanent schema diff - only half paid." } ], "out_of_scope_findings": [ "NOT FILED, handed back with its measurement: the `text` field type gets VARCHAR(255) from BOTH migration generators while driver-sql creates an unbounded `text` column. Driven on the same live PostgreSQL 16.13, one insert of a 300-character value into the three tables: the driver's table accepted it (length 300 read back), and BOTH generated tables refused it with 'ERROR: value too long for type character varying(255)'. Authority: createColumn's text-family arm is `col = keyable === null ? table.text(name) : table.string(name, keyable)`, so an unkeyed field with no declared maxLength is unbounded on the platform and capped at 255 in every generated migration. This is a HARD failure of the same family as #15040's 22P02 - not the schema-diff-noise class of this card - and it is the string family, not the temporal one, so it is outside this card's defect class and outside the bounded in-place exemption. I could not file it: the dedupe search is unavailable on this seat (REST 403, and three MCP search_issues attempts returned 422 then 'API rate limit already exceeded'), and filing without a duplicate check is the shape the rules forbid. Requesting the PM file it, or re-dispatch it to me once search is available.", "OBSERVATION, no card requested: `os generate migration --format sql` emits a Postgres-only dialect throughout (JSONB, now(), and now TIMESTAMPTZ) with no dialect flag and no statement anywhere that this is what --format sql means. That is a documentation/UX question rather than a defect, and it is the same 'which dialect does the generator claim to match' question the nullability ruling has to answer, so it is noted here rather than raised on its own." ] }Generated by Claude Code
os-litant commentedon Sep 6, 2026
维护者速读 — the TYPE half is settled and landed in #16070; the other two rows are yours
Dispatched by the
domain:cliexecution PM seat (#6024). The card's two divergences were graded separately, and a third was found. Two-minute version:Settled by measurement, no ruling needed — landed in #16070
The SQL format's bare
TIMESTAMPistimestamp WITHOUT time zone, while both knex producers of the same columns yieldtimestamptz. So one producer of three was simply wrong, and nothing had to be decided.generateMigrationSql's two audit lines now emitTIMESTAMPTZ.The harm is driven, not argued. Two defaulted rows inserted 6 ms apart under
TimeZone='UTC'and'Asia/Tokyo'landed 09:00:00.006 apart in the zone-naive table, and 00:00:00.011 apart after the fix. The driver's own table was 3 ms apart throughout.One bounded in-place fix rides with it:
FIELD_TYPE_SQL_MAP.datetimecarried the identical literal for the identical reason with the identical measured consequence, and repairing only the audit columns would have left a generated migration wherecreated_atisTIMESTAMPTZand a declareddatetimefield two lines above it isTIMESTAMP. The temporal class was enumerated first;datetimeis its only divergent member.information_schema.columnsrather than compared offline.CURRENT_TIMESTAMPand the SQL format'snow()textually apart. So "match the driver byte for byte" has a second row to pay — see below.Your decision, and it is ONE decision with two rows
Both rows are the same "does the generator match the driver byte for byte" question about the same two columns. Settling one without the other pays half the cost, so they want ruling together.
Row 1 — nullability.
driver-sqlleavescreated_at/updated_atnullable; both generators emit NOT NULL. Nothing fails either way, which is exactly why it is a ruling and not a repair.Row 2 — the default expression. The SQL format emits
DEFAULT now(); both knex paths emitCURRENT_TIMESTAMP. On Postgres these are the same instant (both aretransaction_timestamp()), but PG 16 keeps them textually apart in the catalog, so a schema differ comparing default text reports it forever.notNullable(), and is well-defined only on PostgresnotNullable()packages/drivers, which triage fences as authority rather than subject — adomain:enginehand-off at the manual floor, ⛔ not this lane's to reachThe dev agent recommends B, on the landed precedent rather than on taste: #15040 faced the same question about the
idcolumn in this same file and moved the generator toward the driver, on the principle that a scaffold reproduces what the platform creates rather than improving on it.It then named two facts that could reasonably flip that view, which is why it is a recommendation and not a decision:
stampInsertTimestampsfills both, and where it does not (the documentedskipSchemaSyncposture) the columnDEFAULTfires — so NOT NULL is never load-bearing either;If your view is that NOT NULL is genuinely truer, C is the coherent form of that belief, and A is the only option that knowingly leaves a permanent inconsistency.
⭐ The question that comes before all three
Whoever rules this must also say which dialect the generator claims to match. The driver's audit DDL is dialect-branched —
datetime(3)on MySQL, a canonical ISO default on SQLite — and a Postgres-flavoured generated migration reproduces none of that. "Match the driver" is under-specified until that is answered, and the same question governs #16091 below.Filed from the same measurement
#16091 — both migration generators cap a⚠️ That is a hard failure, not this card's schema-diff-noise class, and it is the string family rather than the temporal one — so it is correctly outside this card. It turns on the same "which dialect / reproduce or improve" question.
textfield atVARCHAR(255)whiledriver-sqlcreates an unboundedtextcolumn. Driven on the same cluster: a 300-character value was accepted by the driver's table and refused by both generated tables withvalue too long for type character varying(255).Carriers
needs:contract-reviewis on this card and on PR #16070 together. Clause ② was graded by the seat from the delivered diff: mechanical floor NO, conformance limb YES — the shipped face isos generate migration, whose emitted migration is its product; the input class is the authoreddatetimefield type plus the builtin audit columns; andTIMESTAMPandtimestamptzwere both already published in this tree and disagreeing, which makes moving that input class from one to the other a re-selection rather than a new verdict. A contract review is dispatched.Generated by Claude Code
docs(changeset): cite ADR-0053 D-B4 as the governing decision (#15521)
claude commentedon Sep 6, 2026
Ruling recorded — B on BOTH rows, plus:
--format sqldeclares itself Postgres-only (director seat, summon #16, 2026-09-06T05:04:37Z)Provenance (who / verbatim / where): maintainer, 2026-09-06, live director chat, answering batch #53 item 2 (A leave both generators at NOT NULL · B drop NOT NULL and move the default spelling, both rows together · C move the driver; recommendation B plus the dialect declaration). Verbatim: 「已经没fable 了,以上同意,然后你下班」 — 「以上同意」 adopts the batch as presented.
get_session05:03Z =claude-opus-5, NOTCONTRACT_REVIEW_TIER. The adjudication is the maintainer's, transcribed with provenance; this seat adjudicated nothing.Governing text: the triage fence on this card —
packages/driversis the authority, not the subject; and the landed precedent of #15040 in this same file, which moved the GENERATOR toward the driver for theidcolumn on the principle that a scaffold reproduces what the platform creates rather than improving on it. No protocol surface: generated migrations are a developer-facing artifact.Freshness: card re-read in this stroke — 5 comments, unchanged since the seat's 维护者速读 (5555775790).
Ruled — B, both rows in one PR, because they are one question about the same two columns.
generateMigrationSqlandgenerateMigrationTsstop emittingNOT NULLoncreated_at/updated_at, matchingcreateAuditTimestampColumn. The dev's own measurement is adopted as the reason NOT NULL is not load-bearing either:stampInsertTimestampsfills both on every platform write, and where it does not (the documentedskipSchemaSyncposture) the column DEFAULT fires.DEFAULT now()becomesCURRENT_TIMESTAMP, soinformation_schema.column_defaultreads the same text on both sides and a schema differ stops reporting the pair forever. ⛔ Settling only row 1 pays half the cost the card is about.os generate migration --format sqlclaims to match the driver on PostgreSQL only, and now says so — one sentence in the command's help text and one in the format's docblock. It already emits JSONB,now()and (since PR fix(cli): generated SQL migrations give timestamp columns their time zone #16070) TIMESTAMPTZ with no dialect flag; the driver's audit DDL is dialect-branched (datetime(3)on MySQL, a canonical ISO default on SQLite) and the SQL format reproduces none of it. ⛔ No MySQL or SQLite claim is made or implied.packages/cli/src/commands/generate-builtin-id-column.pin.test.ts— headed "Recorded divergence, NOT coverage, and NOT a ruling" — is theitto edit: it becomes an agreement pin asserting the three producers now match on type, nullability and default text. ⛔ Do not add a second case and ⛔ do not delete it.packages/drivers, which this card fences as authority.Clause-②: no on the delivered shape — generated scaffold output is a developer-facing artifact, not a published contract; the type half that WAS clause-② already landed in PR #16070. The dispatching seat re-grades from the actual diff.
textatVARCHAR(255)whiledriver-sqlcreates an unboundedtextcolumn): same principle, same file, same authority — the generator follows the driver. That card is alreadypm:queue · domain:cliand needs no decision-box trip; a pointer is posted there.State transition, one stroke:
needs-user-decisionoff;pm:dispatchedand the assignee stay — thedomain:cliseat continues.bug · domain:cli · finding · priority:p3retained.Generated by Claude Code
os-litant commentedon Sep 6, 2026
Claim: branch
claude/issue-15521-audit-stamp-nullability-and-default, sessionsession_01D47qPfEWVPmhguWgBZCi5N.Continuation of an earlier
os-devrun on this card that the 09:40Z container restart killed mid-flight. The implementation was already committed and pushed by that run; no report survived it, so this seat re-measured every claim at its own head rather than inheriting a green it could not see. No work was redone.Files claimed:
packages/cli/src/commands/generate.tspackages/cli/src/commands/generate-builtin-id-column.pin.test.ts.changeset/cli-migration-audit-stamp-nullability-and-default.mdpackages/drivers/**is untouched — it is the authority on this card, never the subject, per the ruling's item 5.Clause two, graded from the delivered diff, per limb:
--formatflag description string, and it NARROWS a claim (it now states PostgreSQL-only and disclaims MySQL and SQLite) rather than widening one.Clause-②: no
Graded from the diff rather than inherited: the ruling anticipated this answer, and the diff independently reaches it on both limbs.
Generated by Claude Code
os-litant commentedon Sep 6, 2026
os-dev-report
{ "issue": 15521, "status": "done", "branch": "claude/issue-15521-audit-stamp-nullability-and-default", "pr": "https://github.com/objectstack-ai/objectstack/pull/16278", "premise_still_valid": true, "summary": "Continuation of a run the 09:40Z container restart killed. THE PREDECESSOR'S COMMIT 52145d48e37 ALREADY CONTAINED THE WHOLE IMPLEMENTATION, and re-reading it against the ruling confirms all five items were delivered in code: generateMigrationSql drops NOT NULL and spells CURRENT_TIMESTAMP; generateMigrationTs replaces table.timestamps(true, true) with the driver's own line per column; the --format help text and generateMigrationSql's docblock both state PostgreSQL-only; the recorded-divergence `it` was EDITED in place into an agreement pin (case count 8 before, 8 after, none added or deleted); packages/drivers/** untouched. WHAT THIS SEAT ADDED: pushed the unpushed merge c5bae68fa1f, merged current origin/main to 2e586a127b6 and pushed it, created the PR (none existed), and re-measured EVERY claim from scratch because no measurement of the predecessor's survived to be audited — dependency build, 120 tests, a predicted ablation with blob-hash proofs, a live-PostgreSQL re-drive of all three producers, and all 57 gate families. Also posted the Claim comment with Clause-② graded from the diff, and filed one out-of-scope finding. No work was redone and no inherited green was trusted.", "tests": "ALL AT HEAD 2e586a127b6 (worktree clean and identical for every reading below; local == remote).\n\n(1) DEPENDENCY CLOSURE FIRST — `pnpm --filter '@objectstack/cli^...' build` via the shared verify lock: `os-verify-lock: VERDICT command-exit 0 · held the lock 401s`.\n\n(2) TESTS — the six files that import generate.js (`pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2`): `Test Files 6 passed (6)` / `Tests 120 passed (120)`, `VERDICT command-exit 0`.\n\n(3) LIVE POSTGRESQL 16.13 — the evidence standard this card was held to. A private cluster (port 54321, superuser `os`) was recovered and all three producers driven into it, each into its own schema so the emitted text ran unmodified: the driver's Postgres arm via knex, the typescript format by IMPORTING the generated migration and invoking its `up(db)`, the sql format by executing its DDL verbatim. Read back from information_schema.columns: all six rows `timestamp with time zone / null=YES / default=CURRENT_TIMESTAMP`, `distinct tuples: 1`, `VERDICT: ALL THREE PRODUCERS AGREE`.\n\n(4) ABLATION — falsification conditions written down BEFORE the run, one per ruled row, with predicted directions. REBUILD: none needed, and this is a property of the resolution rather than an omission — the pin imports `./generate.js`, a relative same-package specifier vitest resolves straight to src/generate.ts with no exports/dist hop, so no dist can go stale between mutation and measurement; the observed red independently proves the test read the mutated bytes. MUTATION CONFIRMED ON DISK by blob hash, not by an editor exit code: on-disk 195280715f4 (fixed) replaced by 5c1d13b24d7, asserted equal to origin/main's blob AND unequal to the fixed blob, with anchor-text counts alongside (`NOT NULL DEFAULT now()` 0 -> 2, `DEFAULT CURRENT_TIMESTAMP` 2 -> 0, driver audit line 1 -> 0). RESULT, exactly as predicted: `Test Files 1 failed | 5 passed (6)` / `Tests 2 failed | 118 passed (120)` — precisely the two named cases (zone-AWARE type; nullability and default text) and no others; and the live cluster flipped to `distinct tuples: 3` / `VERDICT: PRODUCERS DISAGREE` with p_sqlgen at null=NO default=now(), p_tsgen at null=NO, p_driver unmoved. RESTORE PROVEN BY OBSERVED STATE, never by an exit code: on-disk blob back to 195280715f4 == HEAD blob, `git diff HEAD` empty, `git status --porcelain` empty, anchor counts restored. Restore ran under `trap ... EXIT INT TERM` on absolute paths resolved from `git rev-parse --show-toplevel`, and used `git checkout HEAD -- ABSPATH` rather than a bare checkout.\n\n(5) GATES — `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` run TWICE: the plain form's `Reconciliation — 57 famil(ies)` is the number asserted against; `--commands` harvested exactly 57 invocations and, as warned, prints no Reconciliation line. All 57 run locally: 54 exited 0, ZERO RED, 3 NOT MEASURED. The three are quoted from their own verdict text, not scored off exit codes: `check:dual-build-cjs-loads` — 'PREREQUISITE NOT MET ... This is NOT a pass: nothing was measured'; `check:i18n-coverage` — 'Nothing was compared ... says NOTHING about whether any declared label went untranslated'; `check:type-check-debt` — self-test passed and its plain sibling check:type-check-coverage passed, but its --re-measure whole-tree tsc sweep was STOPPED DELIBERATELY by this seat (SIGTERM to an explicit PID, exit=143) after holding the shared lock ~19 minutes with two agents queued. That last one is a DECLARED NARROWING, reported as neither pass nor red. Every exit code captured redirect-then-capture; none read after a pipe.\n\nNOT COVERED, stated rather than implied: the dialect-scope row (help text + docblock) has NO executable detector — it stayed green under the ablation because nothing asserts it, and is verified by reading only.", "mcp_calls": "8", "open_questions": [], "out_of_scope_findings": [ "filed as #16279: no test ever executes the migration DDL `os generate migration` emits — both formats are pinned as TEXT only; scaffold-emission-typechecks covers GENERATOR_SCAFFOLD_TARGETS and generate-scaffold-validates covers metadata scaffolds, so neither reaches it. Unassigned, labelled `finding`, deduped against a working search channel." ] }Notes for the dispatching seat, outside the JSON:
nofrom the diff, per limb, in the Claim comment above. Both generators are pure config-to-text producers that accept the same input and reject nothing; no export, flag, spec key or error code is added; the one signature-adjacent edit is the--formatdescription string, which narrows a claim rather than widening a surface.patchon@objectstack/cli(v17.3.0,private: false), per AGENTS.md lines 1017-1020 — a bug fix in a released package. Notskip-changeset, and the wrong-class#13510precedent was not used.docs/adr/**,.claude/**,skills/**,AGENTS.md,CLAUDE.mdorcontent/docs/releases/.Fixes #15521on line 1 and the only closing keyword in the body.Generated by Claude Code
fix(cli): generated migrations emit the character column driver-sql c…
fix(cli): generated SQL migrations give timestamp columns their time …
fix(cli): generated migrations emit the character column driver-sql c…