Filed from an app repo (objectstack-ai/hotclm, a CLM sample app on @objectstack/spec ^17.0.0, runtime 17.3.0, better-sqlite3 driver). Measured, not reasoned — the measurements are below and each negative has its positive control. ⛔ Unassigned, ungraded; routing is triage's.
Dedup: field-required-notnull-explicit returns 0 issues in this repo, open or closed. Related but not this: #14193 (ADR-0113's Context row dated — closed, PR #15160; it is the record that makes this card legible) · #13743 (the notice's wording duplicated across three commands — closed; about the wording, not its truth).
What the boot says
An artifact authored at protocol floor 17.0.0, loaded on runtime 17.3.0:
⚠ Boot diagnostics — 1 warning logged during startup:
WARN [MetadataPlugin] artifact 'dist/objectstack.json' predates this runtime's spec
(authored engines.protocol floor 17.0.0, runtime spec 17.3.0) — converted 40 site(s)
forward via ADR-0087 conversion 'field-required-notnull-explicit'
(first at objects[0].fields.name.storage.notNull). … Update the source to 'storage.notNull: true'.
Two claims are made there: the metadata has been converted forward (i.e. its original meaning preserved under the new spelling), and the author should write that spelling into the source to keep it once protocol 18 retires the conversion.
The measurement: those two are not the same metadata
Same repo, same runtime, same driver, same database file. required: true on 40 fields across 11 objects; the only variable is whether storage: { notNull: true } is written in the source or supplied by the conversion.
|
source has required: true only (conversion runs) |
source has both (conversion silent) |
| ADR-0087 warning |
1 — "converted 40 site(s) forward" |
0 |
NOT NULL columns created in a fresh database |
0 |
40 |
[schema-drift] lines on an existing database |
0 |
40 (tighten_not_null, severity: error, category: destructive) |
INSERT INTO clm_party (id) VALUES (?) |
ACCEPTED |
REFUSED — NOT NULL constraint failed: clm_party.name |
INSERT INTO clm_clause (id) VALUES (?) |
ACCEPTED |
REFUSED — NOT NULL constraint failed: clm_clause.title |
Both inserts rolled back. The 40 NOT NULL columns in the right-hand column map 1:1 onto the 40 declared sites, none missing and none extra. The two rows of the table are the same 40 fields with the same declared required: true; only the spelling differs.
⇒ A conversion described as forward-preserving produces metadata that behaves differently from the source it tells you to write. The conversion's output leaves the columns nullable; the prescribed source makes them NOT NULL. Whichever is right, they cannot both be what protocol 18 will mean.
The second leg was re-measured on a container where the auth plugin failed to load entirely (#16186) — the DDL result is identical, so it does not depend on auth, seeding, or plugin order.
Why this is a contradiction and not just a gap
ADR-0113 (0113-required-write-contract-vs-column-constraint.md, Accepted 2026-07-30) split the two axes on purpose: required is the write-time contract and "NOT a column constraint"; storage.notNull is the physical column, "absent = the column stays nullable even under required: true". Its P0 shipped, and sql-driver.ts#createColumn keys off storage.notNull alone — the triage ruling on #14193 confirms exactly this and says the pre-P0 wording had merely outlived its decision.
The ADR-0087 conversion adds storage: { notNull: true } wherever it finds required: true. That is precisely the implication ADR-0113 abolished. So one of these must be true, and the platform should say which:
Reading A — the conversion is over-reaching. If required never implied a column constraint under 17.x, the conversion is inventing one. Then it should not add storage.notNull at all, and the warning should not tell authors to write it: today it instructs every app on a ^17.0.0 range to tighten its columns as the remedy for a deprecation notice.
Reading B — the conversion is right about old artifacts, and the runtime is not applying it. If the implication genuinely held at floor 17.0.0, converting forward is correct — but then the converted metadata must reach the SQL driver, and it does not: the boot that logged "converted 40 site(s)" created 40 nullable columns and reported no drift. Under this reading the conversion is a read-path shim that announces a storage guarantee it does not deliver, and protocol 18 will silently relax those columns for real.
I cannot settle A vs B from an app repo — it turns on whether required bound the column in shipped 17.0.0, which is your record, not mine.
The consequence that made this worth filing
The instruction in the warning is not a no-op, and it does not look like a schema change to the person following it:
- On a fresh database it creates 40
NOT NULL columns.
- On an existing database it produces 40
[schema-drift] findings, tighten_not_null, severity: error, category: destructive — the class the platform deliberately never applies unattended, requiring a backfill ceremony.
- Boot diagnostics still reports 0 warnings in that state, so the drift lines are the only signal.
In our case the tightening was wanted, and we took it deliberately (objectstack-ai/hotclm#8, PR objectstack-ai/hotclm#22). But we took it because we measured the columns, not because the warning told us what it would do — the warning presents it as clearing a deprecation. An app that follows the same instruction against a populated production database gets a destructive migration it did not know it was asking for.
Note the trigger is a declared floor, not an artifact's actual age: this app was authored in September 2026, well after ADR-0113, and its required: true was written with 17.3.0 semantics. It gets the conversion only because its dependency range says ^17.0.0. Any app on a permissive range is treated as pre-ADR-0113 metadata regardless of when it was written — which is a reasonable default for a conversion, and a bad one for a destructive inference.
Reproduce
git clone https://github.com/objectstack-ai/hotclm && cd hotclm && pnpm install
git checkout 9025992 # required: true only
rm -rf .objectstack/data && pnpm dev --seed-admin # logs the ADR-0087 warning
# -> PRAGMA table_info: 0 NOT NULL columns; INSERT INTO clm_party (id) succeeds
git checkout baf5cc3 # + storage: { notNull: true }, 40 insertions, 0 deletions
rm -rf .objectstack/data && pnpm dev --seed-admin # no warning
# -> 40 NOT NULL columns; the same INSERT fails with NOT NULL constraint failed: clm_party.name
Reverse-control for the zero: on the same database, count NOT NULL columns across all tables — the before database returns 0 across all 37, which is the platform's own posture and not an artefact of reading the wrong table.
Boundaries
Filed from an app repo (
objectstack-ai/hotclm, a CLM sample app on@objectstack/spec ^17.0.0, runtime 17.3.0,better-sqlite3driver). Measured, not reasoned — the measurements are below and each negative has its positive control. ⛔ Unassigned, ungraded; routing is triage's.Dedup:
field-required-notnull-explicitreturns 0 issues in this repo, open or closed. Related but not this: #14193 (ADR-0113's Context row dated — closed, PR #15160; it is the record that makes this card legible) · #13743 (the notice's wording duplicated across three commands — closed; about the wording, not its truth).What the boot says
An artifact authored at protocol floor 17.0.0, loaded on runtime 17.3.0:
Two claims are made there: the metadata has been converted forward (i.e. its original meaning preserved under the new spelling), and the author should write that spelling into the source to keep it once protocol 18 retires the conversion.
The measurement: those two are not the same metadata
Same repo, same runtime, same driver, same database file.
required: trueon 40 fields across 11 objects; the only variable is whetherstorage: { notNull: true }is written in the source or supplied by the conversion.required: trueonly (conversion runs)NOT NULLcolumns created in a fresh database[schema-drift]lines on an existing databasetighten_not_null,severity: error,category: destructive)INSERT INTO clm_party (id) VALUES (?)NOT NULL constraint failed: clm_party.nameINSERT INTO clm_clause (id) VALUES (?)NOT NULL constraint failed: clm_clause.titleBoth inserts rolled back. The 40
NOT NULLcolumns in the right-hand column map 1:1 onto the 40 declared sites, none missing and none extra. The two rows of the table are the same 40 fields with the same declaredrequired: true; only the spelling differs.⇒ A conversion described as forward-preserving produces metadata that behaves differently from the source it tells you to write. The conversion's output leaves the columns nullable; the prescribed source makes them
NOT NULL. Whichever is right, they cannot both be what protocol 18 will mean.The second leg was re-measured on a container where the auth plugin failed to load entirely (#16186) — the DDL result is identical, so it does not depend on auth, seeding, or plugin order.
Why this is a contradiction and not just a gap
ADR-0113 (
0113-required-write-contract-vs-column-constraint.md, Accepted 2026-07-30) split the two axes on purpose:requiredis the write-time contract and "NOT a column constraint";storage.notNullis the physical column, "absent = the column stays nullable even underrequired: true". Its P0 shipped, andsql-driver.ts#createColumnkeys offstorage.notNullalone — the triage ruling on #14193 confirms exactly this and says the pre-P0 wording had merely outlived its decision.The ADR-0087 conversion adds
storage: { notNull: true }wherever it findsrequired: true. That is precisely the implication ADR-0113 abolished. So one of these must be true, and the platform should say which:Reading A — the conversion is over-reaching. If
requirednever implied a column constraint under 17.x, the conversion is inventing one. Then it should not addstorage.notNullat all, and the warning should not tell authors to write it: today it instructs every app on a^17.0.0range to tighten its columns as the remedy for a deprecation notice.Reading B — the conversion is right about old artifacts, and the runtime is not applying it. If the implication genuinely held at floor 17.0.0, converting forward is correct — but then the converted metadata must reach the SQL driver, and it does not: the boot that logged "converted 40 site(s)" created 40 nullable columns and reported no drift. Under this reading the conversion is a read-path shim that announces a storage guarantee it does not deliver, and protocol 18 will silently relax those columns for real.
I cannot settle A vs B from an app repo — it turns on whether
requiredbound the column in shipped 17.0.0, which is your record, not mine.The consequence that made this worth filing
The instruction in the warning is not a no-op, and it does not look like a schema change to the person following it:
NOT NULLcolumns.[schema-drift]findings,tighten_not_null,severity: error,category: destructive— the class the platform deliberately never applies unattended, requiring a backfill ceremony.In our case the tightening was wanted, and we took it deliberately (objectstack-ai/hotclm#8, PR objectstack-ai/hotclm#22). But we took it because we measured the columns, not because the warning told us what it would do — the warning presents it as clearing a deprecation. An app that follows the same instruction against a populated production database gets a destructive migration it did not know it was asking for.
Note the trigger is a declared floor, not an artifact's actual age: this app was authored in September 2026, well after ADR-0113, and its
required: truewas written with 17.3.0 semantics. It gets the conversion only because its dependency range says^17.0.0. Any app on a permissive range is treated as pre-ADR-0113 metadata regardless of when it was written — which is a reasonable default for a conversion, and a bad one for a destructive inference.Reproduce
Reverse-control for the zero: on the same database, count
NOT NULLcolumns across all tables — the before database returns 0 across all 37, which is the platform's own posture and not an artefact of reading the wrong table.Boundaries
docs/adr/**.better-sqlite3). Before acting, re-measure on a dialect where the DDL path differs; the ADR-0104 work (driver-sql(schema-drift): during #15989's dual-encoding window a staticJSON_COLUMN_FIELD_TYPEScannot serve both moved and unmoved deployments — ⚠️ the body's "reports the ruled end-state" framing is MEASURED FALSE, see comment 5588614136 #16184) shows how much the drivers can diverge.compile.ts/lint.ts/validate.ts(The ADR-0087 conversion-notice wording is duplicated verbatim across all three authoring commands, and no gate holds the three equal #13743, closed) — if the remedy sentence changes, all three say it.