Skip to content

Commit 2d3e255

Browse files
authored
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.
1 parent 7d7521f commit 2d3e255

23 files changed

Lines changed: 605 additions & 87 deletions
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/spec": major
3+
"@objectstack/objectql": major
4+
"@objectstack/driver-sql": major
5+
---
6+
7+
feat!: ADR-0113 — `required` is a write contract; the column constraint becomes the explicit `storage.notNull`
8+
9+
`field.required` bound three meanings to one knob (write check, `NOT NULL` DDL,
10+
drift expectation), so tightening any invariant on a deployed object was a
11+
destructive migration blocked by the very legacy nulls that motivated it — the
12+
reason `criteria_json`'s mandatory-in-substance contract lived in three
13+
imperative guards instead of one declaration.
14+
15+
Split, with the **non-regression invariant** as the unifying rule — *a write
16+
may not take a record from compliant to violating; a pre-existing violation
17+
does not block writes that leave it in place*:
18+
19+
- `required: true` = the write contract, uniformly on new and deployed objects:
20+
insert must provide; **an update PATCHing `null` into a required field is now
21+
rejected** (it silently passed before); omitted fields never block, so legacy
22+
null rows rest. The column stays nullable.
23+
- `storage: { notNull: true }` = the explicit physical constraint, owning the
24+
DDL (`sql-driver` `createColumn`) and the destructive drift ceremony.
25+
Orthogonal to `required` — all four combinations are legitimate, including
26+
the engine-populated column (`storage.notNull` without `required`).
27+
- `requiredWhen` inherits the same invariant: flipping the condition true
28+
without providing the field is rejected (the write *creates* the violation);
29+
a row violating since before the rule tightened no longer locks out
30+
unrelated edits (#3929's objection, cured). `storage.notNull` ×
31+
`requiredWhen` rejects at parse (`FieldSchema.superRefine`).
32+
- **Pre-17 sources keep their exact meaning** via the migration-chain-only
33+
`field-required-notnull-explicit` conversion: `os migrate meta` stamps
34+
`storage.notNull` onto every previously-required field — writing down what
35+
the old text already meant. The loader never infers semantics from the
36+
physical column.
37+
- Drift compares nullability against `storage.notNull`; a column stricter than
38+
its declaration is `needs_confirm` (never auto-applied — dev auto-reconcile
39+
no longer silently strips a stray `NOT NULL`), and silent when the field is
40+
write-gated by `required`.

content/docs/references/data/field.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ const result = Address.parse(data);
7676
| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'toggle' \| 'select' \| 'multiselect' \| 'radio' \| 'checkboxes' \| 'lookup' \| 'master_detail' \| 'tree' \| 'user' \| 'image' \| 'file' \| 'avatar' \| 'video' \| 'audio' \| 'formula' \| 'summary' \| 'autonumber' \| 'composite' \| 'repeater' \| 'record' \| 'location' \| 'address' \| 'code' \| 'json' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode' \| 'progress' \| 'tags' \| 'vector'>` || Field Data Type |
7777
| **description** | `string` | optional | Tooltip/Help text |
7878
| **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. |
8081
| **searchable** | `boolean` | optional | Is searchable |
8182
| **multiple** | `boolean` | optional | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. |
8283
| **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 |

content/docs/releases/v17.mdx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,39 @@ empty shape must state its predicate, or the object should use `sharingModel`.
395395
tombstoned with a fix-it prescription; `os migrate meta` deletes the key
396396
mechanically, and policy outcomes are identical with or without it.
397397

398+
### `required` is a write contract; the column constraint is `storage.notNull` (ADR-0113)
399+
400+
`field.required` used to mean three things through one knob: the write check,
401+
the physical `NOT NULL`, and the drift expectation. Bound together, tightening
402+
any invariant on a deployed object was a destructive migration blocked by the
403+
very legacy nulls that motivated it — so real invariants ended up as imperative
404+
guards (three of them for `sys_sharing_rule.criteria_json`) or didn't happen.
405+
Split in v17:
406+
407+
- **`required: true`** is the write-time contract, uniformly: an insert must
408+
provide a non-null value, an update may not null it out — a PATCH clearing a
409+
required field is now rejected (it silently passed before) — and legacy null
410+
rows rest: a write that doesn't touch the field never blocks. The column
411+
stays nullable.
412+
- **`storage: { notNull: true }`** is the explicit physical constraint. It owns
413+
the `NOT NULL` DDL and the destructive-migration ceremony (backfill first).
414+
Declaring it at field creation is free; declaring it over existing nulls is
415+
loud.
416+
- **`requiredWhen` inherits the same non-regression rule**: a write that flips
417+
the condition true without providing the field is rejected (it *creates* the
418+
violation), while a row violating since before the rule tightened no longer
419+
locks out unrelated edits. `storage.notNull` × `requiredWhen` is rejected at
420+
parse — a conditional contract cannot be an unconditional constraint.
421+
- **Pre-17 sources keep their exact meaning**: `os migrate meta` stamps
422+
`storage: { notNull: true }` onto every previously-required field
423+
(`field-required-notnull-explicit`) — under the old semantics that column
424+
*was* created `NOT NULL`, so the rewrite writes down what the text already
425+
meant. Migration-chain-only: the loader never guesses.
426+
- **Drift**: nullability now compares against `storage.notNull`. A column
427+
stricter than its declaration is `needs_confirm` (ratify or relax — dev
428+
auto-reconcile no longer silently strips a stray `NOT NULL`), and silent when
429+
the field is `required` (the write gate makes the constraint unreachable).
430+
398431
### Membership grade is not a capability channel (ADR-0108, #3723)
399432

400433
`sys_member.role` answers "what is your standing in this organization", not
@@ -1020,6 +1053,12 @@ objectui commits on top of the pin 16.1.0 shipped.
10201053
metadata (`os migrate meta` does it; none ever had an effect). To gate a tool,
10211054
gate the underlying action (`action.requiredPermissions`); to withdraw one,
10221055
remove it from the skills/agents that reference it.
1056+
- **Required fields:** run `os migrate meta` — it stamps
1057+
`storage: { notNull: true }` onto every pre-17 `required: true` field so your
1058+
columns keep their exact constraints. New fields: `required: true` alone
1059+
write-gates with a nullable column; add `storage.notNull` when you want the
1060+
DDL. Audit any client that PATCHes `null` into required fields — that write
1061+
is now rejected.
10231062
- **Membership:** move business capability off `sys_member.role` and onto
10241063
positions (`sys_user_position`).
10251064
- **SDK callers:** remove calls to `client.permissions.*`, `client.realtime.*`,

docs/adr/0113-required-write-contract-vs-column-constraint.md

Lines changed: 90 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0113: `required` is a write-time contract — the column constraint becomes its own, explicitly-authored axis
22

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.
44
**Deciders**: ObjectStack Protocol Architects
55
**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)
66
**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.
7878

7979
## Decision — proposed rulings
8080

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.)
88107

89108
### D2 — the column constraint becomes explicit
90109

@@ -118,21 +137,39 @@ contract), not from the column. `criteria_json` gets its asterisk back, and
118137
client-side validation aligns with what the server will actually reject —
119138
removing the last reason for the objectui#2962-style mirror hints.
120139

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
144+
loader guessing:
145+
146+
- **The `field-required-notnull-explicit` conversion** (D2 table, protocol-17
147+
step) stamps `storage: { notNull: true }` onto every `required: true` field
148+
of a pre-17 source. Under the old semantics that column WAS created
149+
`NOT NULL`, so the rewrite writes down what the text already meant — a pure
150+
semantic explicitization, lossless by construction. It is
151+
**migration-chain-only** (retired from the load path): this is a default
152+
flip, not a rename, and a loader that auto-applied it would stamp the
153+
constraint back onto 17-authored sources that deliberately omit it.
154+
- The earlier draft's "loader treats an already-NOT NULL column as
155+
`storage.notNull`" is **rejected as self-contradictory** — inferring
156+
semantics from the physical column at load is itself the history-dependent
157+
behaviour this ADR exists to eliminate.
158+
- New fields: `required: true` alone creates a **nullable** column with the
159+
write-contract gate — uniformly, on new and old objects alike. The
160+
history-dependent alternative (new objects still get `NOT NULL`) would make
161+
correct authoring depend on a fact absent from the author's context — when
162+
the object was first deployed — which an AI author (ADR-0033) cannot know
163+
from source. Authors who want the constraint say so; at creation time the
164+
ceremony is free (no rows, no backfill).
165+
- **Drift direction** (refined during implementation): a column *stricter*
166+
than its declaration is reported `needs_confirm` (ratify with
167+
`storage.notNull` or deliberately relax; never auto-applied) — **except**
168+
when the field is `required: true`, where it is *silent*: that is every
169+
pre-17 source after a runtime upgrade, the write gate makes the column
170+
constraint unreachable, and nagging every legacy required field would bury
171+
real drift. A declaration stricter than the column stays the destructive
172+
ceremony it always was.
136173

137174
### D6 — `criteria_json` is the first consumer
138175

@@ -145,29 +182,38 @@ pointer as evidence.
145182

146183
## Rollout (proposed)
147184

148-
- **P0** (`@objectstack/spec` + `objectql`): the storage property + D1
149-
validator semantics + D3 drift change. Additive; no tenant behaviour change.
185+
- **P0 — DONE** (this ADR's landing PR): the storage property + parse-seam
186+
exclusivity; record-validator null-out rejection; rule-validator
187+
non-regression; driver DDL + drift rewired; the explicitization conversion
188+
+ chain step.
150189
- **P1** (objectui): D4 marker + client validation from the write contract.
151190
- **P2**: D6 criteria_json flip + guard consolidation; sweep for other
152191
"mandatory in substance, optional in metadata" fields (the empty-state
153192
registry's `closed` entries are the seed list).
154193

155-
Not v17-blocking: additive surface, no breaking change. Target: early 17.x.
156-
157-
## Open questions for adjudication
158-
159-
- **Q1 — spelling.** `storage: { notNull: true }` (proposed: room for future
160-
storage knobs — collation, computed defaults) vs a flat
161-
`requiredEnforcement: 'write' | 'column'` (one knob, no nesting, but closes
162-
the namespace).
163-
- **Q2 — the new-field default.** This draft says `required: true` alone
164-
creates a nullable column (uniform semantics; the constraint is opt-in).
165-
The alternative — new objects still get `NOT NULL`, deployed objects don't —
166-
keeps today's clean-database behaviour but makes the same declaration mean
167-
different DDL depending on when it was authored, which is the kind of
168-
history-dependent semantics ADR-0087 exists to eliminate.
169-
- **Q3 — `requiredWhen` interplay.** The conditional variant evaluates
170-
server-side against the merged record on update (#3929 rejected it for
171-
criteria_json because it blocks legacy-row edits). Should `requiredWhen`
172-
adopt D1's insert-vs-update asymmetry too? Out of scope here unless the
173-
deciders want it folded in.
194+
Lands inside the unreleased 17.0.0 train (the DDL-emission change and the two
195+
validator tightenings ride the major).
196+
197+
## Resolved questions (adjudicated 2026-07-30)
198+
199+
- **Q1 — spelling: `storage: { notNull: true }`.** The decisive argument is
200+
orthogonality: the write contract and the column constraint form a genuine
201+
2×2, and every cell exists in the platform's own system objects — `required`
202+
alone is the criteria_json posture, `storage.notNull` alone is the
203+
engine-populated column (audit fields, the tenant column). A single
204+
`requiredEnforcement` enum cannot express the storage-only cell at all, and
205+
hands the author a wrong option on every required field. `notNull` is also
206+
vocabulary a model already knows from the SQL corpus — no invented term to
207+
hallucinate values for. The namespace earns future entries one *enforced*
208+
knob at a time (ADR-0078); nothing is pre-reserved.
209+
**Rider:** `storage.notNull` × `requiredWhen` is rejected at the parse seam
210+
(`FieldSchema.superRefine`) — when the condition is false the contract
211+
permits null but the column would refuse it; two gates that cannot both be
212+
honest are a contradiction the author must resolve.
213+
- **Q2 — uniform semantics** (folded into D5 above). Semantics conditioned on
214+
deployment history are unreasonable-about by construction for an author —
215+
human or model — who sees only the source.
216+
- **Q3 — folded in and generalized** (D1 above): rather than `requiredWhen`
217+
"adopting" `required`'s asymmetry, both are corollaries of the one
218+
non-regression invariant. The knobs stay one family with one temporal
219+
semantics; no divergence window ever exists.

0 commit comments

Comments
 (0)