You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
driver-sql: a declared field named id / created_at / updated_at is silently discarded by initObjects — declared type, length and constraints all ignored with no diagnostic #12015
Observed while converting sql-driver-aggregation-conformance.test.ts to the live-dialect matrix (#11456), where the fixture object declares id: { type: 'text', name: 'id' } and I needed to know what that produced on MySQL before trusting the conversion. Filed unassigned.
What was observed
SqlDriver.initObjects emits the three builtin columns itself, then skips any declared field that collides with one — packages/drivers/driver-sql/src/sql-driver.ts @ 1958979fc6:
continue, with no warning, no throw, and no record anywhere that the author's declaration was discarded. The same builtinColumns skip appears on the shard path (line ~8643) and governs the ADD COLUMN diff (line ~9125), so the declaration is inert on create, on alter and on shards alike.
Measured on live PostgreSQL 16.13: an object declaring id: { type: 'text' } gets id varchar(255) PRIMARY KEY — table.string('id'), not TEXT. The declared type is not what lands and nothing says so.
Why it matters
The driver is right to own its primary key; the defect is that it disagrees with the author in silence. An author who writes id: { type: 'number' } expecting a numeric key gets a varchar key and a green boot. That is the declared-≠-enforced shape this repo treats as a first-class hazard, and it is squarely in the family of #11431 (the string family ignores maxLength), #11875 (signature/qrcode bounds bind nothing) and #11374 — declared field metadata that the emitter quietly does not honour.
It bites hardest on the surface the platform most wants hard to get wrong: metadata objects authored by an AI, where a silently-dropped declaration produces a schema nobody inspects and a mismatch that only shows up as data behaving oddly much later.
The shape of a fix, and why this is filed rather than done
Two candidates, and choosing between them is a contract decision rather than an edit:
There is also a prior question worth settling first: whether declaring id should be meaningful for the type of the key rather than merely refused, which is a bigger surface than either option above.
⛔ No source change was made under #11456 — that card is a test-harness conversion and this is a driver-behaviour question, so it is recorded here instead.
Maintainer ruling — A: a loud load-time warning on all three paths; the refuse door waits for an inventory
Source: maintainer, 2026-08-25, live PM chat (decision-inbox batch 8 review, session session_01KWRU3s15AJz7PGW7a7wdCh), verbatim: 「同意」 — accepting the batch's presented recommendation for this card: A.
initObjects' silent continue on a declared field colliding with a builtin column becomes a named, loud load-time warning — stating the field, the object, and that the platform owns the column, so the author learns at boot that their declared type/length/constraints were not honoured. All three paths carry it: create, the shard path (:8643), and the ADD COLUMN diff (:9125) — a warning on one path with silence on the others just moves the trap.
B (refuse boot) is not taken now: it is a new rejection door on an existing accept path with the stock inventory unmeasured. If escalation to refusal is ever wanted, it starts with an inventory card (how many existing objects declare builtin column names), not with the door.
C (make the declaration meaningful) is not taken — capability expansion with zero pull; the platform owning its primary key is correct design, the defect was only the silence.
Discipline: Clause-② no (a warning changes no accept/reject behaviour); pin the warning's presence per path so it cannot silently regress to continue.
State: needs-user-decision → pm:queue in the same write; domain:engine stands.
Claim: PM loop (domain:engine seat)
Session: session_01W6HFzyH98W1YaQXhJUJt6o
Branch: claude/issue-12015-builtin-column-collision-warning
Worktree: objectstack-issue-12015
Domain: domain:engine
Container & model: M, mode:subagent, model: opus
Clause-②: no — a warning moves no accept/reject door (the maintainer ruling says so in as many words, and route B, which would have moved one, is explicitly not taken).
Route is ruled, not open: maintainer 2026-08-25, option A — a named, loud load-time warning; B (refuse boot) is not taken and would need an inventory card first; C (make the declaration meaningful) is not taken. ⛔ Do not re-litigate the route; if implementation shows A is impossible, stop and report rather than pivoting to B or C.
Serial constraints cleared
Measured by symbol on origin/main (packages/drivers/driver-sql/src/sql-driver.ts, 15,248 lines). The card's line numbers are stale — it quoted a commit sha (1958979fc6) that reads like a line number. The actual sites:
All three paths carry the warning — the ruling is explicit that a warning on one path with silence on the others just moves the trap. :9127 is a filter predicate rather than a continue, so it needs its own shape; do not assume the same edit works there, and do not quietly skip it because it is awkward.
Carried obligations from the ruling: the #11456 fixture (id: { type: 'text' }) and any in-repo siblings the warning newly fires on are corrected in the same PR — the warning must land with a clean in-repo corpus, not teach everyone to ignore it on day one. Pin the warning's presence per path so it cannot silently regress to continue.
{
"issue": 12015,
"status": "needs_decision",
"branch": "claude/issue-12015-builtin-column-collision-warning",
"pr": "https://github.com/objectstack-ai/objectstack/pull/12109",
"premise_still_valid": true,
"summary": "Route A implemented exactly as ruled and pushed as draft PR #12109: initObjects' silent `continue` on a declared field colliding with a builtin column is now a named load-time warning on all THREE paths — create (`while creating table`), the ADD COLUMN diff (`while syncing existing table`) and the rotation shard sync (`while syncing shard`) — each naming the field, the object, the platform's ownership and carrying `{ table, field, phase }` meta, via one protected `warnBuiltinColumnCollisions` helper. No accept/reject door moved and the emitted DDL is byte-identical (clause ② stays NO). Six pins, one per path plus a no-collision pin and an accept-set pin; ablation on all three legs matched a written prediction. The card's premise holds and was re-measured on SQLite (declared `id: { type: 'text', maxLength: 12 }` lands as `varchar(255)`; MySQL still unmeasured — no live cell here). Status is needs_decision for ONE reason, and it is the ruling's own carried obligation rather than the route: the corpus the warning fires on is not a handful. MEASURED, not inferred — 116 warnings on a real boot of @objectstack/platform-objects alone (45/45 exported objects, id 45 / created_at 42 / updated_at 29), and 77 production object definitions across 12 packages (~197 object,field pairs) repo-wide, plus ~52 fixture literals in ~29 driver test files; zero in YAML/JSON metadata. Those declarations are the house convention, not accidents: `sys_presence.id` is `Field.text({ label: 'Presence ID', required: true, readonly: true })`, whose labels feed the generated *.objects.generated.ts translation files, highlightFields, FLS and sortability, and objectql's sync loop hands the object definition to syncSchema -> initObjects unchanged. So 'land with a clean in-repo corpus' cannot be honoured inside this card without either a 77-file metadata change in 12 packages or narrowing the trigger — and narrowing is a ruling, not a dev decision. The named #11456 fixture IS corrected here (aggregation-conformance no longer declares id; suite green). ⛔ No route change, no narrowing invented on the way past: the branch is the ruled shape and can ship untouched the moment the maintainer says so.",
"tests": "All at HEAD 8a45ee2e4e (the final commit; the gate union was derived and every family run at that same sha), foreground, under scripts/pm/os-verify-lock.sh, exit codes captured before any pipe. (1) `pnpm --filter @objectstack/driver-sql exec vitest run` -> TEST_EXIT=0, its own line: `Test Files 132 passed | 8 skipped (140)`, `Tests 2017 passed | 121 skipped (2138)`. (2) `pnpm --filter @objectstack/driver-sql typecheck` (tsc --noEmit) -> TYPECHECK_EXIT=0. (3) New pin file green 6/6; the corrected #11456 fixture green `19 passed | 2 skipped` (the 2 are the live pg/mysql cells). (4) ABLATION, three legs in one lock hold, direction PREDICTED IN WRITING BEFORE THE RUN as RED (presence pins), one path per leg: remove the 'shard' call -> predicted only the SHARD pin red, observed `1 failed | 5 passed` with `× SHARD path: …`; remove 'create' -> predicted both CREATE pins plus the accept-set pin (all three warn on a create), observed `3 failed | 3 passed`, exactly those three; remove 'alter' -> predicted only the ADD COLUMN diff pin, observed `1 failed | 5 passed` with `× ADD COLUMN diff path: …`. Mutation proven ON DISK before any result was read, by anchored grep counts of the exact call text being removed: target `1 -> 0`, all occurrences `4 -> 3` on every leg (never git diff --stat, never an editor exit code). Rebuild question answered mechanically rather than assumed: `packages/drivers/driver-sql/dist` DID NOT EXIST at ablation time (printed in the ablation log) and the pin imports './index.js' from inside the package, so vitest resolved src/sql-driver.ts — no dist could have hidden the mutation, and the red results prove the source path. Restore verified, not trusted: `git checkout HEAD -- <ABSOLUTE path>` under `trap … EXIT INT TERM`, then `git hash-object` == `git rev-parse HEAD:<path>` (bfdbb89…) and `git status --porcelain` empty after each leg and at the end. (5) Gate union DERIVED at the final commit — `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (its provenance line: derived from objectstack-ai/objectstack at 8a45ee2e4e) — 14 matched + 6 convention-triggered families, ALL RUN, all exit 0, verdict lines quoted from the gates themselves: check:nul-bytes `OK (scanned 6709 text file(s) … no raw ASCII control bytes)`; check:driver-conformance `OK — 45 covered cell(s), 0 in the DEBT ledger, 0 exempt`; check:test-source-alias `OK — 72 packages with tests scanned`; check:cross-package-test-inputs `OK: 16 package(s) read outside themselves, all declared`; check:where-matcher `299 matcher(s) … 0 silently-wrong … none new`; check:published-files, check:slot-lookup, check:type-source-resolution, check:changeset-gate-self-tests, check:objectui-changeset, check:query-options-erasure, check:type-check-coverage, check:engine-double-contract, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check-plugin-teardown-shape, release-rehearsal-clone --self-test, docs-audit/check-affected-docs, docs-audit/check-drift-comment. check:type-check-debt refused at first (`--re-measure cannot run: 50 workspace dependencies … have no built type entry point`), which means NOT MEASURED, so the closure was built exactly as lint.yml does (`70 successful, 70 total`, 6m15s) and it then answered `--re-measure: OK — 32 ledger entr(ies) re-measured in 246.6s, 1897 raw tsc error(s) total, none above its recorded number`. NOT narrowed, nothing skipped. (6) Corpus measurement is a real run, not a grep: platform-objects' built dist driven through SqlDriver.initObjects on SQLite with a spy logger — 45 objects, 45 booted, 0 failed, 116 collision warnings (id 45, created_at 42, updated_at 29).",
"open_questions": [
{
"question": "The ruling's carried obligation — 'the warning must land with a clean in-repo corpus, not teach everyone to ignore it on day one' — is not satisfiable at the measured scale. 116 warnings fire on a stock boot of platform-objects alone (~197 object,field pairs across 77 production object definitions in 12 packages), because declaring `id` / `created_at` / `updated_at` in `fields` is the platform's own convention: those declarations carry label / required / readonly / searchable, feed the generated i18n objects files, highlightFields, FLS and sortability, and only their STORAGE half is discarded. Which way should the obligation be met? (This is a question about the diagnostic's trigger and the corpus, NOT a re-opening of A/B/C — the ruled shape is implemented and pushed and can merge unchanged.)",
"options": [
"A-as-is — merge the branch unchanged: every stock boot prints ~116+ lines about the platform's own objects. Honest and zero further work, but it is precisely the day-one 'train everyone to ignore it' outcome the ruling wanted to avoid, and the customer author it is meant to reach is buried in platform noise.",
"Clean the corpus — strip id/created_at/updated_at from the 77 production object definitions across 12 packages. Large blast radius with no customer pull: it deletes author-facing labels for the three columns every list view shows, moves the generated *.objects.generated.ts translation files in 4 locales, and touches highlightFields/FLS/sortability inputs. It also asserts, without a ruling, that a builtin-named declaration is illegitimate everywhere.",
"Narrow the trigger — fire only when the declaration asks for something the platform's builtin does not deliver (a differing `type`, `maxLength`, `unique`, `defaultValue`, a keyable-length bound…), staying silent for a presentation-only declaration (label / readonly / searchable / i18n) on a builtin name. One predicate in the helper already on the branch; needs a ruling on what counts as storage-affecting, and the #11456 fixture (`id: { type: 'text' }`) would still fire, as would `id: { type: 'number' }` — the exact author error the card names.",
"Move the channel — keep the ruled semantics but emit once per boot as a summary (count + objects + fields) instead of once per object per field, or route it through the schema-drift / `os migrate plan` surface the operator already reads. Keeps every collision recorded without flooding the boot log; the per-path pins survive as-is."
],
"recommendation": "Narrow the trigger (option 3), with the summary channel (option 4) as the fallback if you want zero interpretation of what a declaration means. Four axes: REAL BUSINESS NEED — the harm the card names is an author who declared something the platform silently overrode; measured, that population is a small minority of a corpus dominated by platform objects whose declarations are deliberate and whose storage half nobody expected to bind, so as-is the diagnostic mostly narrates a known-good convention. LONG-TERM SOUNDNESS — contract-first says say the thing that is actually wrong: 'you asked for a type/length/constraint on a column the platform owns' is a real disagreement, 'you gave the primary key a label' is not, and narrating both forever leaves the contract undefined in the log instead of in the code. HARD FOR AI-WRITTEN METADATA TO GET WRONG — this is the decisive axis and it points away from as-is: a line that fires ~116 times on a virgin boot is trained-past by the next author (human or model) before their own object exists, which is the exact failure the ruling named; a rare line stays readable, and `id: { type: 'number' }` still fires under option 3. STARTUP SCOPE — option 3 is one predicate on a diagnostic already written, not capability expansion (it does NOT make the declaration meaningful — the platform still owns the column, route C stays untaken); option 2 is a 77-file, 12-package metadata migration with zero pull. If you prefer to keep the ruled shape verbatim, say so and the branch merges as it stands — it is complete, pinned and green."
}
],
"out_of_scope_findings": []
}
PM review — implementation ACCEPTED, but the ruling's carried obligation is falsified by measurement. Escalating.
PR #12109 (8a45ee2e4e, draft). The dev implemented option A exactly as ruled and did not narrow it on the way past — that was the right call, and it is why the branch can ship unchanged the moment this is answered.
What I verified independently, not from the report
Corpus scale — corroborated. My own count, with a narrower regex than the dev's (^\s*(id|created_at|updated_at):\s*Field\., *.object.ts only, two path depths only): 68 files across 8 package groups — metadata-core, platform-objects, plugin-audit, plugin-security, plugin-sharing, service-messaging, service-realtime, service-storage. Consistent with and slightly under the dev's 77/12, as a narrower probe should be. The dev's 116-warning figure came from a real boot of all 45 exported platform-objects definitions through SqlDriver.initObjects on SQLite, not a grep.
The named example — verbatim.packages/services/service-realtime/src/objects/sys-presence.object.ts:30 carries label: 'Presence ID'.
All three paths carry the warning, at :8689 (shard), :9156 (create), :9191 (alter) through one warnBuiltinColumnCollisions helper at :8656. Including :9127's filter-predicate path, which I flagged at dispatch as needing its own shape — it does, and it got one.
Clause ② holds at no: emitted DDL byte-identical, no accept/reject door moved.
⚠️ The escalation, sharper than the report frames it
The report frames the problem as noise — 116 lines train the next author to ignore it. That is true but understates it. Read the message the branch actually emits:
declared field 'id' collides with a builtin column the platform owns (id, created_at, updated_at) — the declaration is NOT applied. Its declared type, length and constraints are ignored; the platform's own 'id' column stands. Remove the declaration, or rename the field if you meant a column of your own.
"the declaration is NOT applied" is false. The label is applied — it feeds the generated *.objects.generated.ts translation files in 4 locales, highlightFields, FLS and sortability. Only the storage half is discarded.
"Remove the declaration" is harmful advice here. An author who follows it deletes the author-facing label for a column every list view shows.
⇒ The ruled shape does not merely say a true thing too often. On the dominant population it says a untrue thing and gives advice that would cause a regression, 116 times on a stock boot of one package. That is a stronger reason to answer the trigger question than volume alone, and it reframes the fix: option 3 is not "make it quieter", it is "make it true".
The ruling's own words — "the warning must land with a clean in-repo corpus, not teach everyone to ignore it on day one" — were written on the assumption that the siblings were a handful. At ~197 (object, field) pairs across 12 packages, that obligation cannot be met inside this card by correcting the corpus, and narrowing the trigger is a ruling rather than a dev decision. Hence: back to the maintainer, not answered here.
State
pm:dispatched → needs-user-decision. PR #12109 stays draft — ⛔ not flipped ready, not enqueued. The #11456 fixture correction is already in it and is independently correct either way.
The four options and the dev's own four-axis analysis are in the dev report above. My recommendation is option 3 (narrow the trigger) for the correctness reason above rather than the volume reason: fire when the declaration asks for something the platform's builtin does not deliver — a differing type, maxLength, unique, defaultValue, a keyable-length bound — and stay silent when only the presentation half is declared, which the platform does honour. id: { type: 'number' } — the exact author error the card was filed for — still fires. The message then becomes true wherever it appears.
Maintainer ruling — narrow the trigger. The warning fires only when the declaration asks for something the platform's builtin does not deliver.
Source: maintainer, 2026-08-25, live PM chat (session session_01W6HFzyH98W1YaQXhJUJt6o), answering the escalation above: 收窄触发条件.
What this rules
The diagnostic fires when a declaration on a builtin column name asks for storage behaviour the platform's own column does not deliver — a differing type, a maxLength, unique, defaultValue, a keyable-length bound, and anything else in that class.
It stays silent when only the presentation half is declared — label, readonly, searchable, description/group/help and their i18n consequences — because the platform does honour that half. Warning there was not merely noisy, it was false: the branch's current text says "the declaration is NOT applied … Remove the declaration", and on sys_presence.id = Field.text({ label: 'Presence ID' }) the label is applied (it feeds the generated *.objects.generated.ts translation files in 4 locales, highlightFields, FLS and sortability), so following that advice would cause a regression.
⛔ This is not route C. The platform still owns id / created_at / updated_at; the declaration still does not take effect. The narrowing changes what we say, not what we do. No accept/reject door moves; Clause-② stays no.
⛔ Route B (refuse boot) stays untaken, and the inventory that would precede it now exists in this thread if it is ever wanted.
The prior question — whether declaring id should be meaningful for the key's type — remains deliberately unanswered.
Why this and not the alternatives
Merge as-is was rejected because it is the day-one "train everyone to ignore it" outcome the original ruling named, and because on the dominant population the line is untrue.
Clean the corpus (77 object definitions, 12 packages) was rejected: large blast radius, no customer pull, deletes author-facing labels for three columns every list view shows, and asserts without a ruling that a builtin-named declaration is illegitimate everywhere.
Move the channel (boot summary / migrate-plan surface) remains the recorded fallback if the storage-vs-presentation split proves not to be cleanly decidable in code.
Carried obligations for the implementation
The split must be explicit and pinned, not implicit. Enumerate the FieldSchema keys and classify each as storage-affecting or presentation-only, in one place, with the classification itself under test — so a key added later is a deliberate decision rather than a silent default.
Re-measure the corpus after narrowing. The pre-narrowing figure is 116 warnings on a stock boot of platform-objects. Report the post-narrowing figure from the same real boot. If it is not ~0, the remainder are genuine disagreements — name them; do not tune the predicate until the number looks good.
The message must become true. It should say which part was discarded and leave the honoured part alone, and it must not advise removing a declaration whose presentation half is load-bearing.
State: needs-user-decision → pm:dispatched. Continuing on the existing branch claude/issue-12015-builtin-column-collision-warning / PR #12109 rather than cutting a new one.
{
"issue": 12015,
"status": "done",
"branch": "claude/issue-12015-builtin-column-collision-warning",
"pr": "https://github.com/objectstack-ai/objectstack/pull/12109",
"premise_still_valid": true,
"summary": "The narrowing ruled at issuecomment-5408446814 is implemented on the same branch and pushed to the same PR (#12109, draft, be08012875). The trigger is now 'the declaration asks for storage the platform's own column does not deliver', decided in ONE place — the new packages/drivers/driver-sql/src/builtin-column-collision.ts, whose FIELD_KEY_STORAGE_CLASS classifies every FieldSchema key (storage: type/maxLength/unique/defaultValue/storage.notNull/multiple/expression/precision/scale/…; presentation: label/readonly/searchable/required/autonumberFormat/the ADR-0010 provenance markers/…) and whose BUILTIN_COLUMN_DELIVERY records what each builtin column actually provides, read off the emitting lines (id = table.string('id').primary() → varchar(255), NOT NULL, unique, no default; created_at/updated_at = createAuditTimestampColumn → a timestamp defaulted to the DB clock, LEFT NULLABLE). The load-bearing classification is `required`: ADR-0113 makes it the WRITE contract and NOT a column constraint, so it is honoured on the platform's column and calling it discarded was the false sentence. The message is now true: it names the lost attributes and what the column really is ('type: text (the column is string)'), states that the rest of the declaration is honoured as written, and no longer advises removing the declaration — pinned in both directions (the pin asserts the message CONTAINS 'is honoured as written' and does NOT contain 'Remove the declaration'). The three per-path calls and pins stand, the #11456 fixture correction stands, and 11 new pin cases went in. Clause ② stays NO: no accept/reject door moved, DDL byte-identical, the platform still owns the column and the declaration still does not take effect — this changed what we SAY.",
"tests": "All at HEAD be08012875 (the final commit; the gate union was derived and every family run at that sha), foreground, under scripts/pm/os-verify-lock.sh, exit codes captured before any pipe. (1) FULL PACKAGE: `pnpm --filter @objectstack/driver-sql exec vitest run` -> GATE_EXIT=0, `Tests 2028 passed | 121 skipped (2149)` (2017 before this round; +11 pin cases). `pnpm --filter @objectstack/driver-sql typecheck` (tsc --noEmit) -> GATE_EXIT=0. (2) CORPUS RE-MEASURED, same real boot as the pre-narrowing figure — all 45 object definitions exported by @objectstack/platform-objects (built dist) driven through SqlDriver.initObjects on SQLite with a spy logger, 45 booted / 0 failed: 116 warnings BEFORE -> 45 AFTER. The 71 that went away are the created_at/updated_at declarations that describe exactly what createAuditTimestampColumn emits. The 45 that remain are one shape and genuine — every platform object declares `id: Field.text(...)` against a varchar(255) key (sys_migration.id also declares a maxLength that binds nothing) — named individually in the run output and in PR #12109, and filed unassigned as #12131. ⛔ The predicate was NOT tuned toward the number: the classification is derived from what the DDL layer reads and from the emitting lines of the builtin columns, and `id: { type: 'text' }` is the shape the ruling requires to keep firing. (3) ABLATION, five legs, each direction PREDICTED IN WRITING BEFORE THE RUN as RED (presence/absence pins): remove 'shard' -> only the SHARD pin, observed `1 failed | 16 passed`; remove 'create' -> both CREATE pins + the accept-set pin, observed `3 failed | 14 passed`, exactly those; remove 'alter' -> only the ADD COLUMN diff pin, observed `1 failed | 16 passed`; NEUTRALIZE THE DELIVERED-CHECK (report every declared storage key = pre-narrowing behaviour) -> predicted the silence pins plus every message-text pin in both files, observed `10 failed | 7 passed` — 9 as predicted plus the unknown-key case, which the prediction missed and which is correct (its fixture declares a `type` the mutation now reports); reclassify `required` as storage -> predicted the ADR-0113 classification pin + the end-to-end silence pin, observed `3 failed | 14 passed` (those two plus the module's presentation-silence case). ⚠️ HONEST NOTE: the delivered-check leg's FIRST attempt was a NO-OP — its perl substitution never matched, the on-disk proof read `0 -> 0`, and no result was read from it; it was redone with a mutation that provably lands (injected marker `0 -> 1`) and the numbers above are from that run. Mutation proven on disk before any result was read, by anchored counts of the exact text changed (call sites `1 -> 0` target and `4 -> 3` overall; classification `required: presentation` `1 -> 0` and `required: storage` `0 -> 1`). driver-sql/dist was absent (printed in both ablation logs) and both pin files import from inside the package, so vitest read src/ — no rebuild could hide a mutation. Restore verified, not trusted: `git checkout HEAD --` with ABSOLUTE paths under `trap … EXIT INT TERM`, then `git hash-object` == `git rev-parse HEAD:<path>` for both files (db4846b…, 3e78a6c…) and `git status --porcelain` empty after every leg and at the end. (4) GATE UNION DERIVED at the final commit — `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack`, provenance line 'derived from the tree of objectstack-ai/objectstack at commit be08012875', 6 paths vs merge base 8450eeacb — 14 matched + 6 convention-triggered families, ALL RUN, 23 of 23 exit 0. Verdict lines quoted from the gates: check:nul-bytes `OK (scanned 6711 text file(s) … no raw ASCII control bytes)`; check:driver-conformance `OK — 45 covered cell(s), 0 in the DEBT ledger, 0 exempt`; check:test-source-alias `OK — 72 packages with tests scanned`; check:cross-package-test-inputs `OK: 16 package(s) read outside themselves, all declared`; check:where-matcher `299 matcher(s) … 0 silently-wrong … none new`; check:type-check-debt — refused first (`--re-measure cannot run: … no built type entry point`), which means NOT MEASURED, so the closure was built exactly as lint.yml does (`70 successful, 70 total`) and it then answered `--re-measure: OK — 32 ledger entr(ies) re-measured in 234.1s, 1897 raw tsc error(s) total, none above its recorded number`; plus check:published-files, check:slot-lookup, check:type-source-resolution, check:changeset-gate-self-tests, check:objectui-changeset, check:query-options-erasure, check:type-check-coverage, check:engine-double-contract, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check-plugin-teardown-shape, release-rehearsal-clone --self-test, docs-audit/check-affected-docs, docs-audit/check-drift-comment. NOT narrowed, nothing skipped.",
"open_questions": [],
"out_of_scope_findings": [
"filed as #12131: platform-objects — 45 system objects declare `id` as `text` (Field.text) while the platform emits `table.string('id')` = varchar(255); sys_migration.id also declares a maxLength that binds nothing. This is the entire post-narrowing corpus remainder (45 of the original 116), named object-by-object from the real boot. Unassigned, no labels; the fix is a metadata edit across ~45 files in platform-objects/metadata-core/plugins/services (or a storage decision about the key), both outside this card's band. ⛔ NOT 'delete the id declaration' — its label feeds four generated locales, highlightFields, FLS and sortability, and `required` is the ADR-0113 write contract; only the declared `type` disagrees with reality."
]
}
Surface actually touched, for the serial ledger: packages/drivers/driver-sql/src/sql-driver.ts at lines 100–104 (one import) and 8628–8751 (the helper) with call sites at 8718 / 9185 / 9220 — one line past the declared 8600–9200 because the helper grew; plus two new files in the same package. ⛔ No line in 10100–10560 (#11722) or 13275–13900 (#11875) — createColumn at :13794 was read for the classification and not edited.
ACCEPT — PR #12109, narrowing implemented per the maintainer ruling.
The message is now true, and that is pinned in both directions
The whole escalation turned on the emitted line being false for the dominant population. It now reads:
declared field 'id' asks for storage the platform's own 'id' column does not provide — <named attributes>. The platform emits id/created_at/updated_at itself, so THOSE attributes are not applied; the rest of the declaration (label, help text, the ADR-0113 write contract, and everything other layers read) **is honoured as written**. Drop the storage attribute(s) named above, or rename the field if you meant a column of your own.
Verified: Remove the declaration returns 0 hits in the file. The pin asserts the text contains "is honoured as written" and does not contain "Remove the declaration" — so a regression to the false sentence fails by name rather than by taste.
⭐ I checked the one classification that could have made it false in the OTHER direction
required is load-bearing here: the platform leaves created_at / updated_atnullable, so if required were storage, a declared required: true on them would be genuinely undelivered and classifying it presentation would suppress a true warning. The dev cites ADR-0113. I did not take that on the report's word:
ADR-0113 exists, is Accepted (2026-07-30), and its D2 says in as many words: "A new field-level storage property owns the DDL" — required: true // write contract + UI marker, storage: { notNull: true } // column constraint — separate, explicit.
⚠️ The ADR also contains if (field.required) col.notNullable() at sql-driver.ts:4901 — but that is in its problem statement, describing the pre-decision state, not its ruling. Read alone it would have argued the opposite.
So I checked the shipped layer. On origin/main today, sql-driver.ts:14021: if (field.storage?.notNull) col.notNullable(); — with a comment naming the field-required-notnull-explicit conversion. Bare field.required no longer drives the DDL at all.
⇒ The classification is correct against the code, not merely against the document. That was the sharpest way this narrowing could have gone wrong, and it did not.
The corpus number fell out of the predicate — it was not tuned to look good
116 → 45, same real boot (45 platform-objects definitions through SqlDriver.initObjects with a spy logger, 45 booted / 0 failed).
The split is coherent and independently checkable: the 71 that went silent are created_at / updated_at declaring type: 'datetime' + defaultValue: 'NOW()' — precisely what createAuditTimestampColumn emits, so they were never disagreements. The 45 that remain are one shape — every platform object declares id: Field.text(…) against a varchar(255) key — and are named object-by-object, not summarised. That is obligation 2 met exactly: the remainder is enumerated rather than tuned away, and filed as #12131.
The classification lives in one place (builtin-column-collision.ts, FIELD_KEY_STORAGE_CLASS + BUILTIN_COLUMN_DELIVERY) with the delivery side read off the emitting lines rather than assumed — obligation 1 met.
The ablation caught its own void leg
Five legs, each predicted in writing first. ⚠️ The dev reports that the delivered-check leg's first attempt was a no-op — the substitution never matched, the on-disk proof read 0 → 0, and no result was read from it. Redone with a mutation that provably lands (0 → 1).
That is the discipline doing exactly the job it exists for: the "prove the mutation on disk before reading any result" rule is what turned a silently-passing ablation into a caught one. Two devs hit void legs this shift and both caught them the same way.
One prediction miss, reported rather than reconciled: the delivered-check leg predicted 9 red and observed 10 — the extra being an unknown-key case whose fixture declares a type the mutation now reports. Correct behaviour, missed enumeration.
Fences
All hunks in sql-driver.ts at 100–104 (one import), 8628–8751 (the helper), call sites 8718 / 9185 / 9220. The dev declares the 9220 call site as 20 lines past the 8600–9200 band I set, because the helper grew — self-reported rather than found in review, and it lands in territory no card holds. 10100–10560 (#11722) and 13275–13900 (#11875) untouched; createColumn at :13794 was read for the classification and never edited.
Clause ② holds at no: emitted DDL byte-identical, no accept/reject door moved. Route B and route C both stayed untaken.
Flipping to ready and enqueueing once CI converges.
Verified by content on origin/main: is honoured as written → 1 (0 before).
The warning fires on all three paths (create, ADD COLUMN diff, shard) and — after your ruling — only when a declaration asks for storage the platform's own column does not deliver. The corpus went 116 → 45 on the same real boot of platform-objects, and the 45 that remain are one genuine shape (id declared text against a varchar(255) key), named object-by-object in #12131.
The message is now true: it names the lost attributes, states the rest "is honoured as written", and no longer advises removing a declaration whose presentation half is load-bearing. Pinned in both directions.
⚠️ The classification that carried it — required as presentation, not storage — I verified against the shipped layer, not just ADR-0113's text: sql-driver.ts gates notNullable() on field.storage?.notNull, and bare field.required no longer drives DDL at all. Had that been wrong, the narrowed message would have been false in the other direction, suppressing a true warning on created_at/updated_at, which the platform leaves nullable.
Closing as completed; pm:dispatched stripped in the same stroke.
Activity
os-steve commentedon Aug 25, 2026
Triage: decision card —
needs-user-decision+domain:engine, type Bug. 修复方向在「加载期警告 / 直接拒绝 / 让声明生效」间真实分叉,前两者都在既有接受路径上改行为,第三者是能力扩张 —— 人工地板,不入队、不代裁。以下分析按 2026-08-20 裁定从业务角度写。一句话问题:作者(往往是 AI)给对象声明了自己的
id/时间戳字段,平台悄悄用自己的主键顶替 —— 声明的类型、长度、约束全被丢掉,没有任何提示,数据形状与作者以为的不一致,要到很晚数据行为异常时才显形。前提(带 re-check):静默
continue在 create/alter/shard 三条路径上均在(git grep -n "builtinColumns.has" origin/main -- packages/drivers/driver-sql/src/sql-driver.ts);实测 PG16 已复现(声明id: text→ 落地varchar(255))。选项 × 真实代价:
<!-- os-decision-facets -->
maxLengthtoo — a declared 1024 becomes varchar(255) and refuses legitimate writes #11431/signature/qrcodehave nomaxLengthenforcement anywhere, so they cannot join the TEXT family — a data-URI signature is refused at 255 chars and the declared bound binds nothing #11875/driver-sql: the platform-objects schema does not sync onto MySQL — unbounded string fields become TEXT, which MySQL refuses to index #11374 同族);C 扩大契约面 —— 平台拥有主键本身是对的设计,缺的只是响亮性。低摩擦裁决:回一个字母即可;「B,但先盘存量」= 自动落 A 先行 + 盘点卡。
裁后执行(你不用管):A → engine 队,warn 覆盖 create/alter/shard 三路径 + fixture 修正;B → 先派盘点卡,读数回来再派拒绝门。
Generated by Claude Code
os-support-ai commentedon Aug 25, 2026
Maintainer ruling — A: a loud load-time warning on all three paths; the refuse door waits for an inventory
Source: maintainer, 2026-08-25, live PM chat (decision-inbox batch 8 review, session
session_01KWRU3s15AJz7PGW7a7wdCh), verbatim: 「同意」 — accepting the batch's presented recommendation for this card: A.initObjects' silentcontinueon a declared field colliding with a builtin column becomes a named, loud load-time warning — stating the field, the object, and that the platform owns the column, so the author learns at boot that their declared type/length/constraints were not honoured. All three paths carry it: create, the shard path (:8643), and the ADD COLUMN diff (:9125) — a warning on one path with silence on the others just moves the trap.id: { type: 'text' }) and any in-repo siblings the warning newly fires on are corrected in the same PR — the warning must land with a clean in-repo corpus, not teach everyone to ignore it on day one.Discipline: Clause-② no (a warning changes no accept/reject behaviour); pin the warning's presence per path so it cannot silently regress to
continue.State:
needs-user-decision→pm:queuein the same write;domain:enginestands.Generated by Claude Code
os-warren commentedon Aug 25, 2026
Claim: PM loop (
domain:engineseat)Session:
session_01W6HFzyH98W1YaQXhJUJt6oBranch:
claude/issue-12015-builtin-column-collision-warningWorktree:
objectstack-issue-12015Domain:
domain:engineContainer & model: M,
mode:subagent,model: opusClause-②: no — a warning moves no accept/reject door (the maintainer ruling says so in as many words, and route B, which would have moved one, is explicitly not taken).
Route is ruled, not open: maintainer 2026-08-25, option A — a named, loud load-time warning; B (refuse boot) is not taken and would need an inventory card first; C (make the declaration meaningful) is not taken. ⛔ Do not re-litigate the route; if implementation shows A is impossible, stop and report rather than pivoting to B or C.
Serial constraints cleared
Measured by symbol on
origin/main(packages/drivers/driver-sql/src/sql-driver.ts, 15,248 lines). The card's line numbers are stale — it quoted a commit sha (1958979fc6) that reads like a line number. The actual sites::8629const builtinColumns = new Set([...]):8645if (builtinColumns.has(name)) continue;:9078const builtinColumns = new Set([...]):9100if (builtinColumns.has(name)) continue;:9127(name) => !builtinColumns.has(name) && !existingColumns.includes(name)All three paths carry the warning — the ruling is explicit that a warning on one path with silence on the others just moves the trap.
:9127is a filter predicate rather than acontinue, so it needs its own shape; do not assume the same edit works there, and do not quietly skip it because it is awkward.8600–9130.signature/qrcodehave nomaxLengthenforcement anywhere, so they cannot join the TEXT family — a data-URI signature is refused at 255 chars and the declared bound binds nothing #11875 is dispatched in parallel on the13275–13900band (declaredVarcharLength/keyableTextLength/varcharColumnChars). Do not touch it. If your change needs a line in that band, stop and report — that is a serial breach, not a merge to resolve.Carried obligations from the ruling: the #11456 fixture (
id: { type: 'text' }) and any in-repo siblings the warning newly fires on are corrected in the same PR — the warning must land with a clean in-repo corpus, not teach everyone to ignore it on day one. Pin the warning's presence per path so it cannot silently regress tocontinue.Generated by Claude Code
os-warren commentedon Aug 25, 2026
os-dev-report
{ "issue": 12015, "status": "needs_decision", "branch": "claude/issue-12015-builtin-column-collision-warning", "pr": "https://github.com/objectstack-ai/objectstack/pull/12109", "premise_still_valid": true, "summary": "Route A implemented exactly as ruled and pushed as draft PR #12109: initObjects' silent `continue` on a declared field colliding with a builtin column is now a named load-time warning on all THREE paths — create (`while creating table`), the ADD COLUMN diff (`while syncing existing table`) and the rotation shard sync (`while syncing shard`) — each naming the field, the object, the platform's ownership and carrying `{ table, field, phase }` meta, via one protected `warnBuiltinColumnCollisions` helper. No accept/reject door moved and the emitted DDL is byte-identical (clause ② stays NO). Six pins, one per path plus a no-collision pin and an accept-set pin; ablation on all three legs matched a written prediction. The card's premise holds and was re-measured on SQLite (declared `id: { type: 'text', maxLength: 12 }` lands as `varchar(255)`; MySQL still unmeasured — no live cell here). Status is needs_decision for ONE reason, and it is the ruling's own carried obligation rather than the route: the corpus the warning fires on is not a handful. MEASURED, not inferred — 116 warnings on a real boot of @objectstack/platform-objects alone (45/45 exported objects, id 45 / created_at 42 / updated_at 29), and 77 production object definitions across 12 packages (~197 object,field pairs) repo-wide, plus ~52 fixture literals in ~29 driver test files; zero in YAML/JSON metadata. Those declarations are the house convention, not accidents: `sys_presence.id` is `Field.text({ label: 'Presence ID', required: true, readonly: true })`, whose labels feed the generated *.objects.generated.ts translation files, highlightFields, FLS and sortability, and objectql's sync loop hands the object definition to syncSchema -> initObjects unchanged. So 'land with a clean in-repo corpus' cannot be honoured inside this card without either a 77-file metadata change in 12 packages or narrowing the trigger — and narrowing is a ruling, not a dev decision. The named #11456 fixture IS corrected here (aggregation-conformance no longer declares id; suite green). ⛔ No route change, no narrowing invented on the way past: the branch is the ruled shape and can ship untouched the moment the maintainer says so.", "tests": "All at HEAD 8a45ee2e4e (the final commit; the gate union was derived and every family run at that same sha), foreground, under scripts/pm/os-verify-lock.sh, exit codes captured before any pipe. (1) `pnpm --filter @objectstack/driver-sql exec vitest run` -> TEST_EXIT=0, its own line: `Test Files 132 passed | 8 skipped (140)`, `Tests 2017 passed | 121 skipped (2138)`. (2) `pnpm --filter @objectstack/driver-sql typecheck` (tsc --noEmit) -> TYPECHECK_EXIT=0. (3) New pin file green 6/6; the corrected #11456 fixture green `19 passed | 2 skipped` (the 2 are the live pg/mysql cells). (4) ABLATION, three legs in one lock hold, direction PREDICTED IN WRITING BEFORE THE RUN as RED (presence pins), one path per leg: remove the 'shard' call -> predicted only the SHARD pin red, observed `1 failed | 5 passed` with `× SHARD path: …`; remove 'create' -> predicted both CREATE pins plus the accept-set pin (all three warn on a create), observed `3 failed | 3 passed`, exactly those three; remove 'alter' -> predicted only the ADD COLUMN diff pin, observed `1 failed | 5 passed` with `× ADD COLUMN diff path: …`. Mutation proven ON DISK before any result was read, by anchored grep counts of the exact call text being removed: target `1 -> 0`, all occurrences `4 -> 3` on every leg (never git diff --stat, never an editor exit code). Rebuild question answered mechanically rather than assumed: `packages/drivers/driver-sql/dist` DID NOT EXIST at ablation time (printed in the ablation log) and the pin imports './index.js' from inside the package, so vitest resolved src/sql-driver.ts — no dist could have hidden the mutation, and the red results prove the source path. Restore verified, not trusted: `git checkout HEAD -- <ABSOLUTE path>` under `trap … EXIT INT TERM`, then `git hash-object` == `git rev-parse HEAD:<path>` (bfdbb89…) and `git status --porcelain` empty after each leg and at the end. (5) Gate union DERIVED at the final commit — `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (its provenance line: derived from objectstack-ai/objectstack at 8a45ee2e4e) — 14 matched + 6 convention-triggered families, ALL RUN, all exit 0, verdict lines quoted from the gates themselves: check:nul-bytes `OK (scanned 6709 text file(s) … no raw ASCII control bytes)`; check:driver-conformance `OK — 45 covered cell(s), 0 in the DEBT ledger, 0 exempt`; check:test-source-alias `OK — 72 packages with tests scanned`; check:cross-package-test-inputs `OK: 16 package(s) read outside themselves, all declared`; check:where-matcher `299 matcher(s) … 0 silently-wrong … none new`; check:published-files, check:slot-lookup, check:type-source-resolution, check:changeset-gate-self-tests, check:objectui-changeset, check:query-options-erasure, check:type-check-coverage, check:engine-double-contract, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check-plugin-teardown-shape, release-rehearsal-clone --self-test, docs-audit/check-affected-docs, docs-audit/check-drift-comment. check:type-check-debt refused at first (`--re-measure cannot run: 50 workspace dependencies … have no built type entry point`), which means NOT MEASURED, so the closure was built exactly as lint.yml does (`70 successful, 70 total`, 6m15s) and it then answered `--re-measure: OK — 32 ledger entr(ies) re-measured in 246.6s, 1897 raw tsc error(s) total, none above its recorded number`. NOT narrowed, nothing skipped. (6) Corpus measurement is a real run, not a grep: platform-objects' built dist driven through SqlDriver.initObjects on SQLite with a spy logger — 45 objects, 45 booted, 0 failed, 116 collision warnings (id 45, created_at 42, updated_at 29).", "open_questions": [ { "question": "The ruling's carried obligation — 'the warning must land with a clean in-repo corpus, not teach everyone to ignore it on day one' — is not satisfiable at the measured scale. 116 warnings fire on a stock boot of platform-objects alone (~197 object,field pairs across 77 production object definitions in 12 packages), because declaring `id` / `created_at` / `updated_at` in `fields` is the platform's own convention: those declarations carry label / required / readonly / searchable, feed the generated i18n objects files, highlightFields, FLS and sortability, and only their STORAGE half is discarded. Which way should the obligation be met? (This is a question about the diagnostic's trigger and the corpus, NOT a re-opening of A/B/C — the ruled shape is implemented and pushed and can merge unchanged.)", "options": [ "A-as-is — merge the branch unchanged: every stock boot prints ~116+ lines about the platform's own objects. Honest and zero further work, but it is precisely the day-one 'train everyone to ignore it' outcome the ruling wanted to avoid, and the customer author it is meant to reach is buried in platform noise.", "Clean the corpus — strip id/created_at/updated_at from the 77 production object definitions across 12 packages. Large blast radius with no customer pull: it deletes author-facing labels for the three columns every list view shows, moves the generated *.objects.generated.ts translation files in 4 locales, and touches highlightFields/FLS/sortability inputs. It also asserts, without a ruling, that a builtin-named declaration is illegitimate everywhere.", "Narrow the trigger — fire only when the declaration asks for something the platform's builtin does not deliver (a differing `type`, `maxLength`, `unique`, `defaultValue`, a keyable-length bound…), staying silent for a presentation-only declaration (label / readonly / searchable / i18n) on a builtin name. One predicate in the helper already on the branch; needs a ruling on what counts as storage-affecting, and the #11456 fixture (`id: { type: 'text' }`) would still fire, as would `id: { type: 'number' }` — the exact author error the card names.", "Move the channel — keep the ruled semantics but emit once per boot as a summary (count + objects + fields) instead of once per object per field, or route it through the schema-drift / `os migrate plan` surface the operator already reads. Keeps every collision recorded without flooding the boot log; the per-path pins survive as-is." ], "recommendation": "Narrow the trigger (option 3), with the summary channel (option 4) as the fallback if you want zero interpretation of what a declaration means. Four axes: REAL BUSINESS NEED — the harm the card names is an author who declared something the platform silently overrode; measured, that population is a small minority of a corpus dominated by platform objects whose declarations are deliberate and whose storage half nobody expected to bind, so as-is the diagnostic mostly narrates a known-good convention. LONG-TERM SOUNDNESS — contract-first says say the thing that is actually wrong: 'you asked for a type/length/constraint on a column the platform owns' is a real disagreement, 'you gave the primary key a label' is not, and narrating both forever leaves the contract undefined in the log instead of in the code. HARD FOR AI-WRITTEN METADATA TO GET WRONG — this is the decisive axis and it points away from as-is: a line that fires ~116 times on a virgin boot is trained-past by the next author (human or model) before their own object exists, which is the exact failure the ruling named; a rare line stays readable, and `id: { type: 'number' }` still fires under option 3. STARTUP SCOPE — option 3 is one predicate on a diagnostic already written, not capability expansion (it does NOT make the declaration meaningful — the platform still owns the column, route C stays untaken); option 2 is a 77-file, 12-package metadata migration with zero pull. If you prefer to keep the ruled shape verbatim, say so and the branch merges as it stands — it is complete, pinned and green." } ], "out_of_scope_findings": [] }Generated by Claude Code
Generated by Claude Code
os-warren commentedon Aug 25, 2026
PM review — implementation ACCEPTED, but the ruling's carried obligation is falsified by measurement. Escalating.
PR #12109 (
8a45ee2e4e, draft). The dev implemented option A exactly as ruled and did not narrow it on the way past — that was the right call, and it is why the branch can ship unchanged the moment this is answered.What I verified independently, not from the report
^\s*(id|created_at|updated_at):\s*Field\.,*.object.tsonly, two path depths only): 68 files across 8 package groups —metadata-core,platform-objects,plugin-audit,plugin-security,plugin-sharing,service-messaging,service-realtime,service-storage. Consistent with and slightly under the dev's 77/12, as a narrower probe should be. The dev's 116-warning figure came from a real boot of all 45 exportedplatform-objectsdefinitions throughSqlDriver.initObjectson SQLite, not a grep.packages/services/service-realtime/src/objects/sys-presence.object.ts:30carrieslabel: 'Presence ID'.:8689(shard),:9156(create),:9191(alter) through onewarnBuiltinColumnCollisionshelper at:8656. Including:9127's filter-predicate path, which I flagged at dispatch as needing its own shape — it does, and it got one.1 → 0target,4 → 3overall) before any result was read,distprovably absent so no stale artifact could hide it, restore verified bygit hash-object==git rev-parse HEAD:<path>undertrap … EXIT INT TERMwith an absolute path. This is the discipline [finding] The ablationtrap restore EXITsilently did NOT restore — only the byte-identical hash check caught it, and an EMPTY hash must be read as failure #11539/Reverse verification can silently run against a mutated tree:git checkout REF -- PATHwrites the INDEX, so a trap restoring withgit checkout -- PATHreads back the mutation and exits 0 #11648 exist for, done right.The report frames the problem as noise — 116 lines train the next author to ignore it. That is true but understates it. Read the message the branch actually emits:
Against
sys_presence.id = Field.text({ label: 'Presence ID', required: true, readonly: true }):*.objects.generated.tstranslation files in 4 locales,highlightFields, FLS and sortability. Only the storage half is discarded.⇒ The ruled shape does not merely say a true thing too often. On the dominant population it says a untrue thing and gives advice that would cause a regression, 116 times on a stock boot of one package. That is a stronger reason to answer the trigger question than volume alone, and it reframes the fix: option 3 is not "make it quieter", it is "make it true".
The ruling's own words — "the warning must land with a clean in-repo corpus, not teach everyone to ignore it on day one" — were written on the assumption that the siblings were a handful. At ~197
(object, field)pairs across 12 packages, that obligation cannot be met inside this card by correcting the corpus, and narrowing the trigger is a ruling rather than a dev decision. Hence: back to the maintainer, not answered here.State
pm:dispatched→needs-user-decision. PR #12109 stays draft — ⛔ not flipped ready, not enqueued. The #11456 fixture correction is already in it and is independently correct either way.The four options and the dev's own four-axis analysis are in the dev report above. My recommendation is option 3 (narrow the trigger) for the correctness reason above rather than the volume reason: fire when the declaration asks for something the platform's builtin does not deliver — a differing
type,maxLength,unique,defaultValue, a keyable-length bound — and stay silent when only the presentation half is declared, which the platform does honour.id: { type: 'number' }— the exact author error the card was filed for — still fires. The message then becomes true wherever it appears.Generated by Claude Code
os-warren commentedon Aug 25, 2026
Maintainer ruling — narrow the trigger. The warning fires only when the declaration asks for something the platform's builtin does not deliver.
Source: maintainer, 2026-08-25, live PM chat (session
session_01W6HFzyH98W1YaQXhJUJt6o), answering the escalation above: 收窄触发条件.What this rules
type, amaxLength,unique,defaultValue, a keyable-length bound, and anything else in that class.label,readonly,searchable, description/group/help and their i18n consequences — because the platform does honour that half. Warning there was not merely noisy, it was false: the branch's current text says "the declaration is NOT applied … Remove the declaration", and onsys_presence.id = Field.text({ label: 'Presence ID' })the label is applied (it feeds the generated*.objects.generated.tstranslation files in 4 locales,highlightFields, FLS and sortability), so following that advice would cause a regression.id: { type: 'number' }— the exact author error the card was filed for — still fires. So does the [finding] sql-driver-aggregation-conformance.test.ts hard-codes better-sqlite3, so the aggregate-vocabulary standard never runs on Postgres — its pagination sibling already uses the live-dialect cells #11456 fixture'sid: { type: 'text' }.What this does NOT rule
id/created_at/updated_at; the declaration still does not take effect. The narrowing changes what we say, not what we do. No accept/reject door moves; Clause-② stays no.idshould be meaningful for the key's type — remains deliberately unanswered.Why this and not the alternatives
Carried obligations for the implementation
FieldSchemakeys and classify each as storage-affecting or presentation-only, in one place, with the classification itself under test — so a key added later is a deliberate decision rather than a silent default.platform-objects. Report the post-narrowing figure from the same real boot. If it is not ~0, the remainder are genuine disagreements — name them; do not tune the predicate until the number looks good.State:
needs-user-decision→pm:dispatched. Continuing on the existing branchclaude/issue-12015-builtin-column-collision-warning/ PR #12109 rather than cutting a new one.Generated by Claude Code
os-warren commentedon Aug 25, 2026
os-dev-report
{ "issue": 12015, "status": "done", "branch": "claude/issue-12015-builtin-column-collision-warning", "pr": "https://github.com/objectstack-ai/objectstack/pull/12109", "premise_still_valid": true, "summary": "The narrowing ruled at issuecomment-5408446814 is implemented on the same branch and pushed to the same PR (#12109, draft, be08012875). The trigger is now 'the declaration asks for storage the platform's own column does not deliver', decided in ONE place — the new packages/drivers/driver-sql/src/builtin-column-collision.ts, whose FIELD_KEY_STORAGE_CLASS classifies every FieldSchema key (storage: type/maxLength/unique/defaultValue/storage.notNull/multiple/expression/precision/scale/…; presentation: label/readonly/searchable/required/autonumberFormat/the ADR-0010 provenance markers/…) and whose BUILTIN_COLUMN_DELIVERY records what each builtin column actually provides, read off the emitting lines (id = table.string('id').primary() → varchar(255), NOT NULL, unique, no default; created_at/updated_at = createAuditTimestampColumn → a timestamp defaulted to the DB clock, LEFT NULLABLE). The load-bearing classification is `required`: ADR-0113 makes it the WRITE contract and NOT a column constraint, so it is honoured on the platform's column and calling it discarded was the false sentence. The message is now true: it names the lost attributes and what the column really is ('type: text (the column is string)'), states that the rest of the declaration is honoured as written, and no longer advises removing the declaration — pinned in both directions (the pin asserts the message CONTAINS 'is honoured as written' and does NOT contain 'Remove the declaration'). The three per-path calls and pins stand, the #11456 fixture correction stands, and 11 new pin cases went in. Clause ② stays NO: no accept/reject door moved, DDL byte-identical, the platform still owns the column and the declaration still does not take effect — this changed what we SAY.", "tests": "All at HEAD be08012875 (the final commit; the gate union was derived and every family run at that sha), foreground, under scripts/pm/os-verify-lock.sh, exit codes captured before any pipe. (1) FULL PACKAGE: `pnpm --filter @objectstack/driver-sql exec vitest run` -> GATE_EXIT=0, `Tests 2028 passed | 121 skipped (2149)` (2017 before this round; +11 pin cases). `pnpm --filter @objectstack/driver-sql typecheck` (tsc --noEmit) -> GATE_EXIT=0. (2) CORPUS RE-MEASURED, same real boot as the pre-narrowing figure — all 45 object definitions exported by @objectstack/platform-objects (built dist) driven through SqlDriver.initObjects on SQLite with a spy logger, 45 booted / 0 failed: 116 warnings BEFORE -> 45 AFTER. The 71 that went away are the created_at/updated_at declarations that describe exactly what createAuditTimestampColumn emits. The 45 that remain are one shape and genuine — every platform object declares `id: Field.text(...)` against a varchar(255) key (sys_migration.id also declares a maxLength that binds nothing) — named individually in the run output and in PR #12109, and filed unassigned as #12131. ⛔ The predicate was NOT tuned toward the number: the classification is derived from what the DDL layer reads and from the emitting lines of the builtin columns, and `id: { type: 'text' }` is the shape the ruling requires to keep firing. (3) ABLATION, five legs, each direction PREDICTED IN WRITING BEFORE THE RUN as RED (presence/absence pins): remove 'shard' -> only the SHARD pin, observed `1 failed | 16 passed`; remove 'create' -> both CREATE pins + the accept-set pin, observed `3 failed | 14 passed`, exactly those; remove 'alter' -> only the ADD COLUMN diff pin, observed `1 failed | 16 passed`; NEUTRALIZE THE DELIVERED-CHECK (report every declared storage key = pre-narrowing behaviour) -> predicted the silence pins plus every message-text pin in both files, observed `10 failed | 7 passed` — 9 as predicted plus the unknown-key case, which the prediction missed and which is correct (its fixture declares a `type` the mutation now reports); reclassify `required` as storage -> predicted the ADR-0113 classification pin + the end-to-end silence pin, observed `3 failed | 14 passed` (those two plus the module's presentation-silence case). ⚠️ HONEST NOTE: the delivered-check leg's FIRST attempt was a NO-OP — its perl substitution never matched, the on-disk proof read `0 -> 0`, and no result was read from it; it was redone with a mutation that provably lands (injected marker `0 -> 1`) and the numbers above are from that run. Mutation proven on disk before any result was read, by anchored counts of the exact text changed (call sites `1 -> 0` target and `4 -> 3` overall; classification `required: presentation` `1 -> 0` and `required: storage` `0 -> 1`). driver-sql/dist was absent (printed in both ablation logs) and both pin files import from inside the package, so vitest read src/ — no rebuild could hide a mutation. Restore verified, not trusted: `git checkout HEAD --` with ABSOLUTE paths under `trap … EXIT INT TERM`, then `git hash-object` == `git rev-parse HEAD:<path>` for both files (db4846b…, 3e78a6c…) and `git status --porcelain` empty after every leg and at the end. (4) GATE UNION DERIVED at the final commit — `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack`, provenance line 'derived from the tree of objectstack-ai/objectstack at commit be08012875', 6 paths vs merge base 8450eeacb — 14 matched + 6 convention-triggered families, ALL RUN, 23 of 23 exit 0. Verdict lines quoted from the gates: check:nul-bytes `OK (scanned 6711 text file(s) … no raw ASCII control bytes)`; check:driver-conformance `OK — 45 covered cell(s), 0 in the DEBT ledger, 0 exempt`; check:test-source-alias `OK — 72 packages with tests scanned`; check:cross-package-test-inputs `OK: 16 package(s) read outside themselves, all declared`; check:where-matcher `299 matcher(s) … 0 silently-wrong … none new`; check:type-check-debt — refused first (`--re-measure cannot run: … no built type entry point`), which means NOT MEASURED, so the closure was built exactly as lint.yml does (`70 successful, 70 total`) and it then answered `--re-measure: OK — 32 ledger entr(ies) re-measured in 234.1s, 1897 raw tsc error(s) total, none above its recorded number`; plus check:published-files, check:slot-lookup, check:type-source-resolution, check:changeset-gate-self-tests, check:objectui-changeset, check:query-options-erasure, check:type-check-coverage, check:engine-double-contract, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check-plugin-teardown-shape, release-rehearsal-clone --self-test, docs-audit/check-affected-docs, docs-audit/check-drift-comment. NOT narrowed, nothing skipped.", "open_questions": [], "out_of_scope_findings": [ "filed as #12131: platform-objects — 45 system objects declare `id` as `text` (Field.text) while the platform emits `table.string('id')` = varchar(255); sys_migration.id also declares a maxLength that binds nothing. This is the entire post-narrowing corpus remainder (45 of the original 116), named object-by-object from the real boot. Unassigned, no labels; the fix is a metadata edit across ~45 files in platform-objects/metadata-core/plugins/services (or a storage decision about the key), both outside this card's band. ⛔ NOT 'delete the id declaration' — its label feeds four generated locales, highlightFields, FLS and sortability, and `required` is the ADR-0113 write contract; only the declared `type` disagrees with reality." ] }Surface actually touched, for the serial ledger:
packages/drivers/driver-sql/src/sql-driver.tsat lines 100–104 (one import) and 8628–8751 (the helper) with call sites at 8718 / 9185 / 9220 — one line past the declared8600–9200because the helper grew; plus two new files in the same package. ⛔ No line in10100–10560(#11722) or13275–13900(#11875) —createColumnat:13794was read for the classification and not edited.Generated by Claude Code
Generated by Claude Code
os-warren commentedon Aug 25, 2026
ACCEPT — PR #12109, narrowing implemented per the maintainer ruling.
The message is now true, and that is pinned in both directions
The whole escalation turned on the emitted line being false for the dominant population. It now reads:
Verified:
Remove the declarationreturns 0 hits in the file. The pin asserts the text contains "is honoured as written" and does not contain "Remove the declaration" — so a regression to the false sentence fails by name rather than by taste.⭐ I checked the one classification that could have made it false in the OTHER direction
requiredis load-bearing here: the platform leavescreated_at/updated_atnullable, so ifrequiredwere storage, a declaredrequired: trueon them would be genuinely undelivered and classifying it presentation would suppress a true warning. The dev cites ADR-0113. I did not take that on the report's word:required: true // write contract + UI marker,storage: { notNull: true } // column constraint — separate, explicit.if (field.required) col.notNullable()atsql-driver.ts:4901— but that is in its problem statement, describing the pre-decision state, not its ruling. Read alone it would have argued the opposite.origin/maintoday,sql-driver.ts:14021:if (field.storage?.notNull) col.notNullable();— with a comment naming thefield-required-notnull-explicitconversion. Barefield.requiredno longer drives the DDL at all.⇒ The classification is correct against the code, not merely against the document. That was the sharpest way this narrowing could have gone wrong, and it did not.
The corpus number fell out of the predicate — it was not tuned to look good
116 → 45, same real boot (45
platform-objectsdefinitions throughSqlDriver.initObjectswith a spy logger, 45 booted / 0 failed).The split is coherent and independently checkable: the 71 that went silent are
created_at/updated_atdeclaringtype: 'datetime'+defaultValue: 'NOW()'— precisely whatcreateAuditTimestampColumnemits, so they were never disagreements. The 45 that remain are one shape — every platform object declaresid: Field.text(…)against avarchar(255)key — and are named object-by-object, not summarised. That is obligation 2 met exactly: the remainder is enumerated rather than tuned away, and filed as #12131.The classification lives in one place (
builtin-column-collision.ts,FIELD_KEY_STORAGE_CLASS+BUILTIN_COLUMN_DELIVERY) with the delivery side read off the emitting lines rather than assumed — obligation 1 met.The ablation caught its own void leg
Five legs, each predicted in writing first.⚠️ The dev reports that the delivered-check leg's first attempt was a no-op — the substitution never matched, the on-disk proof read
0 → 0, and no result was read from it. Redone with a mutation that provably lands (0 → 1).That is the discipline doing exactly the job it exists for: the "prove the mutation on disk before reading any result" rule is what turned a silently-passing ablation into a caught one. Two devs hit void legs this shift and both caught them the same way.
One prediction miss, reported rather than reconciled: the delivered-check leg predicted 9 red and observed 10 — the extra being an unknown-key case whose fixture declares a
typethe mutation now reports. Correct behaviour, missed enumeration.Fences
All hunks in
sql-driver.tsat100–104(one import),8628–8751(the helper), call sites8718 / 9185 / 9220. The dev declares the9220call site as 20 lines past the8600–9200band I set, because the helper grew — self-reported rather than found in review, and it lands in territory no card holds.10100–10560(#11722) and13275–13900(#11875) untouched;createColumnat:13794was read for the classification and never edited.Clause ② holds at no: emitted DDL byte-identical, no accept/reject door moved. Route B and route C both stayed untaken.
Flipping to ready and enqueueing once CI converges.
Generated by Claude Code
os-warren commentedon Aug 25, 2026
LANDED — PR #12109 merged as
84de7e371d.Verified by content on
origin/main:is honoured as written→ 1 (0 before).The warning fires on all three paths (create, ADD COLUMN diff, shard) and — after your ruling — only when a declaration asks for storage the platform's own column does not deliver. The corpus went 116 → 45 on the same real boot of
platform-objects, and the 45 that remain are one genuine shape (iddeclaredtextagainst avarchar(255)key), named object-by-object in #12131.The message is now true: it names the lost attributes, states the rest "is honoured as written", and no longer advises removing a declaration whose presentation half is load-bearing. Pinned in both directions.
requiredas presentation, not storage — I verified against the shipped layer, not just ADR-0113's text:sql-driver.tsgatesnotNullable()onfield.storage?.notNull, and barefield.requiredno longer drives DDL at all. Had that been wrong, the narrowed message would have been false in the other direction, suppressing a true warning oncreated_at/updated_at, which the platform leaves nullable.Closing as completed;
pm:dispatchedstripped in the same stroke.Generated by Claude Code
fix(driver-sql,lint): classify the new rows key (presentation) and in…
fix(driver-sql): name the declared field a builtin column discards, o…
feat(spec): declare rows (multiline editors) and options[].descriptio…
fix(objects): stop declaring maxLength on the platform's own id column (