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
feat(spec,objectql,driver-sql)!: ADR-0113 accepted — required is a write contract, storage.notNull is the column constraint (#4031)
The three adjudications on ADR-0113's open questions, and P0.
Q1 storage.notNull spelling: the write contract and the column constraint form a genuine 2x2 (required alone = the criteria_json posture; storage.notNull alone = the engine-populated column) — a single enum cannot express the storage-only cell. Q2 uniform semantics by EXPLICITIZATION: the field-required-notnull-explicit conversion stamps storage.notNull onto every pre-17 required field (writing down what the old text already meant, migration-chain-only — the loader never infers semantics from the physical column; the draft's load-time inference is rejected as self-contradictory). Q3 generalized: the non-regression invariant — a write may not take a record from compliant to violating; a pre-existing violation does not block writes that leave it in place — with required and requiredWhen as corollaries.
Implementation: FieldSchema.storage.{notNull} + parse-seam exclusivity vs requiredWhen (superRefine); record-validator rejects a PATCH nulling a required field (the update path previously skipped the required check entirely — a real hole closed); rule-validator applies the invariant to requiredWhen (the #3929 legacy-row lockout cured, its pinning test deliberately flipped); sql-driver createColumn + schema-drift read storage.notNull (a column stricter than its declaration is needs_confirm, silent when write-gated; dev auto-reconcile no longer strips a stray NOT NULL — supersedes the #2178 self-heal); conversion + chain step replayed by the composability gate; ADR-0113 -> Accepted; v17 notes + upgrade checklist; field ledger; major changesets.
spec 6903 / objectql 1179 / driver-sql 485 / wasm 126 green; all gates green.
|**description**|`string`| optional | Tooltip/Help text |
78
78
|**format**|`string`| optional | Format string (e.g. email, phone) |
79
-
|**required**|`boolean`| optional | Is required |
79
+
|**required**|`boolean`| optional | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. |
80
+
|**storage**|`{ notNull?: boolean }`| optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. |
80
81
|**searchable**|`boolean`| optional | Is searchable |
81
82
|**multiple**|`boolean`| optional | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. |
82
83
|**unique**|`boolean \| 'global'`| optional | Unique constraint. true = unique within the tenant (composite with the tenant column on tenant-scoped objects); 'global' = unique platform-wide across all tenants |
Copy file name to clipboardExpand all lines: docs/adr/0113-required-write-contract-vs-column-constraint.md
+90-44Lines changed: 90 additions & 44 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1,6 +1,6 @@
1
1
# ADR-0113: `required` is a write-time contract — the column constraint becomes its own, explicitly-authored axis
2
2
3
-
**Status**: Proposed (2026-07-30) — **awaiting adjudication**. Drafted from the #3896 close-out; no code in this ADR has been implemented.
3
+
**Status**: Accepted (2026-07-30) — Q1/Q2/Q3 adjudicated same day (uniform semantics; `storage.notNull` spelling; the non-regression invariant unifies `required` and `requiredWhen`). **P0 implemented**: spec knob + parse-seam exclusivity, record-validator null-out rejection, rule-validator non-regression, driver DDL + drift rewired, `field-required-notnull-explicit` conversion. P1 (Console required-marker from the write contract) and P2 (criteria_json flip) follow.
4
4
**Deciders**: ObjectStack Protocol Architects
5
5
**Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove), [ADR-0078](./0078-no-silently-inert-metadata.md) (no silently inert metadata), the #3896/#3929 sharing-criteria case study, `driver-sql/schema-drift.ts` (the drift classifier whose `destructive` class this ADR exists to route around)
6
6
**Consumers**: `@objectstack/spec` (`FieldSchema`), `@objectstack/objectql` (`validation/record-validator.ts`), `@objectstack/driver-sql` (`sql-driver.ts` column DDL, `schema-drift.ts`), `@objectstack/lint`, objectui form renderers (the required marker)
@@ -78,13 +78,32 @@ polices in the other direction.
78
78
79
79
## Decision — proposed rulings
80
80
81
-
### D1 — `required` means the write contract
82
-
83
-
`required: true` asserts: **an insert must provide a non-null value, and an
84
-
update may not null it out.** Existing rows are untouched — a legacy null row
85
-
remains readable and editable so long as the write does not touch the required
86
-
field. This is exactly the semantics #3929 hand-built for `criteria_json`,
87
-
promoted from three guards to the platform's one word.
81
+
### D1 — the non-regression invariant (unifies `required` and `requiredWhen`)
82
+
83
+
> **A write may not take a record from compliant to violating; a pre-existing
84
+
> violation does not block writes that leave it in place.**
85
+
> Operationally: *reject iff the merged post-state violates AND the pre-state
86
+
> complied.* (On insert the pre-state complies vacuously.)
87
+
88
+
`required` is the degenerate always-true-condition case: an insert must
89
+
provide a non-null value; an update may not null it out; a legacy null row
90
+
remains readable and editable so long as the write does not touch the field.
91
+
This is exactly the semantics #3929 hand-built for `criteria_json`, promoted
92
+
from three guards to one platform word.
93
+
94
+
`requiredWhen` inherits the same invariant with the condition generalized —
95
+
which also catches the case the old merged-record check missed in the OTHER
96
+
direction and over-blocked in this one: an update that flips the condition
97
+
true without providing the field **creates** the violation and is rejected,
98
+
while a row violating since before the rule tightened no longer locks out
99
+
unrelated edits (the #3929 objection, cured rather than worked around).
100
+
101
+
One deliberate over-approximation, chosen to keep the hot write path free of
102
+
prior-state reads for the unconditional knob: an explicit `null` written onto
103
+
an *already-null* legacy required field is rejected even though the pre-state
104
+
already violated. That write was a no-op carrying a false claim; rejecting it
105
+
costs nothing and needs no prior fetch. (`requiredWhen` evaluates against the
106
+
prior record it already fetches, so it applies the exact rule.)
88
107
89
108
### D2 — the column constraint becomes explicit
90
109
@@ -118,21 +137,39 @@ contract), not from the column. `criteria_json` gets its asterisk back, and
118
137
client-side validation aligns with what the server will actually reject —
119
138
removing the last reason for the objectui#2962-style mirror hints.
120
139
121
-
### D5 — back-compat is the deliberate asymmetry
122
-
123
-
For existing metadata, `required: true` today implies both meanings, so the
124
-
migration must not silently drop the column constraint fields already have:
125
-
126
-
- Fields whose column is **already `NOT NULL`** (created as required): loader
127
-
treats them as `required + storage.notNull` — nothing changes, no DDL, no
128
-
drift.
129
-
- Fields declared required whose column is nullable: today that is
130
-
un-declarable without destructive drift; under this ADR it becomes the D1
131
-
posture. This set is currently EMPTY by construction (nobody could declare
132
-
it), so no deployed tenant changes behaviour.
133
-
- New fields: `required: true` alone creates a **nullable** column with a
134
-
write-contract gate. Authors who want the constraint say so (Q2 disputes
135
-
this default).
140
+
### D5 — back-compat by explicitization, never by inference (Q2 resolved: uniform)
141
+
142
+
`required: true` has exactly ONE meaning at any point in time — the write
143
+
contract — and the transition is carried by rewriting old sources, not by the
0 commit comments