Skip to content

Commit a1840d7

Browse files
committed
docs(adr): renumber to ADR-0137; cite the rulings the record was silent about
`0136` is claimed by PR #18480's `0136-declared-journeys-as-priority-anchor.md`, added ~42 hours earlier. `scripts/check-adr-anchors.mjs` prescribes the NEW record taking the next free number, and renumbering an already-accepted record was ruled out — before it is referenced is the only cheap moment. `0137` re-verified free: absent from `docs/adr/` on `origin/main` (which tops out at 0135) and claimed by none of the 31 open PRs, scanned through the added-file list of each. The scan lit twice on `0136`, so the zero is a reading. Three corrections the record owed: - **Status**: this record declares and implements nothing. D1's authoring refusal is decision batch #122 item 2's, carried by PR #18638 under one ADR-0087 id; D2–D4 are consumer-delivered in objectui#8069. - **Scope boundary**: the gate-slot conversion is RULED and IN FLIGHT, not "filed as a follow-up" — the dangling sentence is gone. The record's claim that converting them "would bake a direction the ruling did not give" is true only of batch #119, and is now stated as what it is: a statement about which ruling authorizes what, not a reason the conversion should wait. - **The hand enumeration is replaced by a citation of #15811's census**, because the hand list had already rotted: it omitted `system/settings-manifest.zod.ts:424` and `:686`, both `visible: SettingsVisibilityInputSchema`. Measured through `SettingsManifestSchema.safeParse` on the built dist: all six refused spellings (`ast`-only, blank `source`, blank bare string × both slots) are ACCEPTED, while a grammar-violating source is REFUSED with `custom@visible` and `custom@specifiers.0.visible` — so the refinement is live at both slots and narrows neither arm. Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9f30a18 commit a1840d7

2 files changed

Lines changed: 106 additions & 58 deletions

File tree

‎docs/adr/0089-unify-visibility-predicate-naming.md‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@
88

99
---
1010

11-
> **Addendum (2026-09-18, #17778) — what the `*When` family does when it cannot RUN is decided by [ADR-0136](./0136-predicate-fault-semantics-are-contract.md), not here.**
12-
> This record unified the family under one NAME and is unchanged by that one. ADR-0136 D1 holds a
13-
> predicate slot to what the engine can actually run (the `FieldSchema` field-rule triad composes
14-
> `PredicateInputSchema`, so an `ast`-only envelope and a blank `source` are refused at authoring);
15-
> D2 refuses the SUBMIT loudly on a fault, naming the field and the rule; D3 keeps visibility
16-
> fail-OPEN at RENDER. A reader who arrived here asking what a broken `visibleWhen` does should
17-
> read that record.
11+
> **Addendum (2026-09-18, #17778) — what the `*When` family does when it cannot RUN is decided by [ADR-0137](./0137-predicate-fault-semantics-are-contract.md), not here.**
12+
> This record unified the family under one NAME and is unchanged by that one. ADR-0137 D1 holds a
13+
> predicate slot to what the engine can actually run — an `ast`-only envelope and a `source` blank
14+
> after trimming are refused at authoring, which is the field-rule row of the rule decision batch
15+
> #122 item 2 gave across every evaluated slot; D2 refuses the SUBMIT loudly on a fault, naming the
16+
> field and the rule; D3 keeps visibility fail-OPEN at RENDER. A reader who arrived here asking what
17+
> a broken `visibleWhen` does should read that record.
1818
1919
---
2020

docs/adr/0136-predicate-fault-semantics-are-contract.md renamed to docs/adr/0137-predicate-fault-semantics-are-contract.md

Lines changed: 99 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
# ADR-0136: A predicate's FAULT semantics are part of the protocol — loud at submit, fail-open at render, and a blank predicate is a declared third state
1+
# ADR-0137: A predicate's FAULT semantics are part of the protocol — loud at submit, fail-open at render, and a blank predicate is a declared third state
22

3-
**Status**: Accepted (2026-09-18) — **D1 implemented here** (`PredicateSchema` / `PredicateInputSchema` compose the evaluated rule; the `FieldSchema` field-rule triad binds them; ADR-0087 D3 entry `field-rule-predicate-evaluated-slot-source-required`). **D2 / D3 / D4 are declared here and delivered by consumers** — the renderer and submit path in objectui#8069, which is `pm:blocked` on this record. D4's spec-side authoring refusal for the action / visibility GATE slots is deliberately **not** in this record's PR; see [Scope boundary](#scope-boundary-what-this-record-does-not-land).
3+
**Status**: Accepted (2026-09-18) — **this record declares; it implements nothing.** D1's authoring refusal is ruled by decision batch #122 item 2 (card #15811, [`5644350409`](https://github.com/objectstack-ai/objectstack/issues/15811#issuecomment-5644350409), 2026-09-12) and lands in PR #18638, which owns the accept-set narrowing across every evaluated slot under one ADR-0087 id — this record's own PR carries **no** schema change, by decision batch #160 item 1 (2026-09-18: 「同意」 to **A**). **D2 / D3 / D4 are declared here and delivered by consumers** — the renderer and submit path in objectui#8069, which is `pm:blocked` on this record. See [Scope boundary](#scope-boundary-what-this-record-does-not-land) for what this record does not land and who lands it.
44
**Deciders**: ObjectStack Protocol Architects (maintainer ruling on objectui#8069, decision batch #119 item 3, 2026-09-12: 「同意」 to **A**, with **Q2 yes** and **Q3 yes**), filed as objectstack#17778 by the director seat
55
**Builds on**: [ADR-0058](./0058-expression-and-predicate-surface.md) (the expression & predicate surface — its D5 predicate failure tiers are the table this record writes the field-rule row of), [ADR-0089](./0089-unify-visibility-predicate-naming.md) (unified the `*When` family under one NAME; this record decides what that family does when it cannot RUN), [ADR-0087](./0087-metadata-protocol-upgrade-contract.md) (conversion-over-notification — D1 lands as a D3 semantic entry because no D2 conversion exists), [ADR-0124](./0124-server-enforces-client-is-courtesy.md) (D1 server-enforces — why a render-side direction is never the whole answer), [ADR-0078](./0078-no-silently-inert-metadata.md) (no silently-inert metadata — a predicate that cannot run is the purest case), [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove), [ADR-0032](./0032-unified-expression-layer.md) (the CEL layer these predicates are written in)
66
**Consumers**: `@objectstack/spec` (`shared/expression.zod.ts` — the predicate contract; `data/field.zod.ts` — the field-rule triad), `@objectstack/objectql` (`validation/rule-validator.ts` — the server-side enforcer whose three fault directions this record measured), `@objectstack/lint` (`validate-expressions.ts`, `validate-visibility-predicates.ts` — the author-time reporters), and the ObjectUI form renderer + submit path (objectui#8069)
@@ -15,13 +15,17 @@ named two. The missing state is **"authored, but the engine cannot run it"** —
1515
because nothing named it, every layer resolved it to its own local fallback and
1616
none of those fallbacks was the author's.
1717

18-
| state | before | after this record |
18+
| state | before | after this decision |
1919
|---|---|---|
2020
| absent | no rule | no rule (unchanged) |
2121
| authored and evaluable | the author's verdict | the author's verdict (unchanged) |
2222
| **authored, blank** | parsed, then silently no-op'd | **refused at authoring** (D1) |
2323
| **authored, not evaluable** | each layer's own fallback, silently | **refused at submit, loudly** (D2) |
2424

25+
"After this decision", not "after this PR": every row of the right-hand column is
26+
carried by someone else — D1 by PR #18638 under decision batch #122 item 2, D2–D4
27+
by the consumers in objectui#8069. This record is the contract, not the landing.
28+
2529
**Decision:** a predicate's fault semantics are **protocol**, not renderer choice.
2630
A predicate slot accepts only what the engine can actually run (D1). A fault at
2731
**submit** refuses the write and names the field and the rule (D2). A fault at
@@ -68,39 +72,64 @@ migration prescription: removing such a key is behaviour-preserving, which makes
6872
it a safe default — and a dishonest one to reach for without noticing that it
6973
records a rule that never ran.
7074

71-
### The narrowing mechanism already existed
72-
73-
This record introduces no new validation machinery. `EvaluatedExpressionSchema`
74-
and `EvaluatedExpressionInputSchema` already spell "an evaluated slot is held to
75-
what the engine can actually run", already publish one sentence for it
76-
(`EVALUATED_EXPRESSION_SOURCE_REQUIRED`), and `FlowEdgeSchema.condition` already
77-
composes them for exactly this defect one family over. What was missing is that
78-
`PredicateSchema` / `PredicateInputSchema` — the aliases whose entire purpose is
79-
to mark a slot as a predicate — composed the **persistence** contract, whose rule
80-
is "`source` OR `ast`". The alias that means "this will be evaluated" pointed at
81-
the schema that does not require evaluability.
75+
### The narrowing mechanism already existed, and it already has an owner
76+
77+
This record introduces no validation machinery and carries none.
78+
`EvaluatedExpressionSchema` and `EvaluatedExpressionInputSchema` already spell
79+
"an evaluated slot is held to what the engine can actually run", already publish
80+
one sentence for it (`EVALUATED_EXPRESSION_SOURCE_REQUIRED`), and
81+
`FlowEdgeSchema.condition` already composes them for exactly this defect one
82+
family over.
83+
84+
Generalising that composition to the rest of the evaluated slots was ruled six
85+
days before this record, on its own card: **decision batch #122 item 2**
86+
(card #15811, comment
87+
[`5644350409`](https://github.com/objectstack-ai/objectstack/issues/15811#issuecomment-5644350409),
88+
2026-09-12, maintainer 「同意」 to **A**). Its item 1 names the population from
89+
a measured census, the field-rule triad included: *「field / option /
90+
grid-column `visibleWhen` / `readonlyWhen` / `requiredWhen`」*. Its item 2 keeps
91+
`ExpressionSchema` / `ExpressionInputSchema` on "`source` OR `ast`". **PR #18638
92+
implements it** — one ADR-0087 id for all 36 declaring positions.
93+
94+
So the authoring refusal this record's D1 states is **not this record's to
95+
carry**, and a second carrier for it would be a second id for one migration.
96+
`PredicateSchema` / `PredicateInputSchema` are **not** that carrier either: they
97+
are a plain alias pair of the persistence contract with zero slot users, they
98+
stay wide with the schema they alias (#18638's own measurement says so), and
99+
retiring them as dead symbols is a separate question on its own measurement.
82100

83101
## Decision
84102

85103
### D1 — A predicate slot accepts only what the engine can run
86104

87-
`PredicateSchema` and `PredicateInputSchema` compose `EvaluatedExpressionSchema` /
88-
`EvaluatedExpressionInputSchema`. An envelope carrying only an `ast`, and a
89-
`source` that is blank after trimming (through the envelope key or the bare-string
90-
shorthand), are refused at authoring with `EVALUATED_EXPRESSION_SOURCE_REQUIRED`.
91-
The `FieldSchema` field-rule triad — `visibleWhen`, `readonlyWhen`, `requiredWhen`
92-
— binds them.
105+
A field-rule predicate is an EVALUATED slot by definition, so the envelope it
106+
accepts must be one the engine can evaluate, and the CEL engine reads `source`
107+
alone (`cel-engine.ts` `evaluate`: "AST-only evaluation not yet supported;
108+
persist `source`"). An envelope carrying only an `ast`, and a `source` that is
109+
blank after trimming (through the envelope key or the bare-string shorthand), are
110+
refused at **authoring** with `EVALUATED_EXPRESSION_SOURCE_REQUIRED` rather than
111+
parsing, registering, and faulting at evaluation time.
112+
113+
⚠️ **This decision is recorded here and carried elsewhere.** It is the
114+
field-rule row of a rule already ruled across every evaluated slot by decision
115+
batch #122 item 2 and implemented by PR #18638 (see [the
116+
Context](#the-narrowing-mechanism-already-existed-and-it-already-has-an-owner)
117+
above): `EvaluatedExpressionSchema` / `EvaluatedExpressionInputSchema` compose
118+
into the slots themselves, and **one** ADR-0087 D3 semantic entry covers the
119+
whole population. This record's own PR changes no schema and registers no
120+
migration id. A reader who arrives at this line asking "where is it enforced"
121+
should read #18638's entry, not look for a second one.
93122

94123
`ExpressionSchema` / `ExpressionInputSchema` are **not** narrowed: they remain the
95124
persistence contract, and `ast` remains an optional opaque structured value there.
96125
An `ast` **beside** a string `source` stays admitted everywhere.
97126

98-
This is an accept-set narrowing and ships with an ADR-0087 D3 semantic entry
99-
(`field-rule-predicate-evaluated-slot-source-required`), because no D2 conversion
100-
can express it: an `ast`-only envelope carries no `source` to lower an AST back
101-
into, and a blank `source` names no predicate to reconstruct. Which of "author the
102-
rule" and "drop the rule" the author meant is not derivable, and the platform does
103-
not guess.
127+
The refusal needs an ADR-0087 D3 **semantic** entry rather than a D2 conversion,
128+
and the reason is worth keeping next to the decision: no conversion can express
129+
it. An `ast`-only envelope carries no `source` to lower an AST back into where
130+
the dialect has no printer, and a blank `source` names no predicate to
131+
reconstruct. Which of "author the rule" and "drop the rule" the author meant is
132+
not derivable, and the platform does not guess.
104133

105134
### D2 — A field-rule predicate that FAULTS refuses the SUBMIT, loudly
106135

@@ -168,28 +197,46 @@ Stated explicitly so that no part of it reads as delivered when it is not
168197
(Prime Directive #10: declared ≠ enforced is the defect this whole record is
169198
about).
170199

200+
- **This record lands no schema change at all.** Its own PR carries the record,
201+
the ADR-0089 pointer and nothing that a runtime or an author can observe. D1's
202+
authoring refusal is #18638's, under batch #122 item 2; the record is here
203+
because the fault semantics D2–D4 state are what a consumer is held to, and a
204+
consumer cannot be held to a direction no protocol states.
171205
- **D2 / D3 / D4 are consequences CONSUMERS deliver.** `packages/spec` carries no
172206
business logic (Prime Directive #2), so the submit refusal, the render direction
173207
and the gate diagnostic are declared here and implemented in objectui#8069.
174208
This record is the contract they are held to.
175-
- **D4's spec-side authoring refusal is NOT applied to the gate slots in this
176-
record's PR.** The action / visibility gate slots — view and page `visibleWhen` /
177-
`visibleOn` / `visibility`, action and bulk-action `visible` / `visibleWhen`,
178-
component `visible` / `visibleWhen`, app nav `visible`, `ObjectFieldGroupSchema`
179-
`visibleWhen`, `RowCrudActionOverride` `visibleWhen` / `disabledWhen`, the
180-
per-OPTION `visibleWhen` and the inline-column `readonlyWhen` / `requiredWhen`
181-
— still compose `ExpressionInputSchema`. Two reasons, and the first is the one
182-
that matters: several of those slots carry their **own** declared fault
183-
directions ("fail-closed", "fail-soft") which are not the field-rule triad's,
184-
and the ruling measured its evidence on the field-rule path. Binding them all to
185-
one rule without re-measuring each declared direction would bake a direction the
186-
ruling did not give, which is exactly what the second constraint above forbids.
187-
Second, `validate-visibility-predicates.ts`'s `celRefusal` currently records the
188-
opposite position for those slots — a blank predicate there "is 'no predicate',
189-
exactly what the author meant" — so converting them is also a behaviour change
190-
in a second package, not a schema swap. That conversion is filed as a follow-up
191-
with the slot inventory, and it is the one place where D4 is currently declared
192-
ahead of its authoring-side enforcement.
209+
- **D4's spec-side authoring refusal for the GATE slots is RULED and IN FLIGHT —
210+
it is not a follow-up, and it is not this record's to give or withhold.** The
211+
action / visibility gate slots are inside decision batch #122 item 2's
212+
population, which was drawn from the measured census on card #15811
213+
([`5629834274`](https://github.com/objectstack-ai/objectstack/issues/15811#issuecomment-5629834274),
214+
36 declaring positions, re-derived by identity on #18638's base) and named in
215+
the ruling as *「action `visibleWhen` / `visible` / `ActionConditionInputSchema`;
216+
app `visible`; settings visibility; … `ui/bulk-action` visibility」*. **Cite that
217+
census rather than re-enumerating it** — a hand list rots, and this record's
218+
did: it omitted `system/settings-manifest.zod.ts:424` and `:686`, both
219+
`visible: SettingsVisibilityInputSchema`, which is
220+
`ExpressionInputSchema.superRefine(…)` — the refinement returns early on a
221+
`source` that is absent or blank (`if (!source) return;`), so it narrows
222+
neither the `ast`-only nor the blank-`source` arm and those two slots sit on
223+
the persistence contract exactly like the rest.
224+
- **What the batch #119 ruling did not give, and what that does not mean.** The
225+
card behind this record measured its evidence on the field-rule path, and
226+
several gate slots carry their **own** declared fault directions
227+
("fail-closed" on `ObjectFieldGroupSchema.visibleWhen` and
228+
`RowCrudActionOverride.visibleWhen`, "fail-soft" on `disabledWhen`) which are
229+
not the field-rule triad's. Converting them **on the strength of batch #119**
230+
would bake a direction that ruling did not give. That is the whole of the
231+
claim: it is a statement about which ruling authorizes what, not a reason the
232+
conversion should wait. Batch #122 item 2 gave exactly that direction six days
233+
earlier, on its own measured census, and #18638 implements it. A second
234+
consideration is a cost, not an objection: `packages/lint`'s
235+
`validate-visibility-predicates.ts` `celRefusal` records the opposite position
236+
for those slots today — a blank predicate there "is 'no predicate', exactly
237+
what the author meant" — so the conversion is a behaviour change in a second
238+
package rather than a schema swap, and #18638 carries that cost with the
239+
narrowing.
193240

194241
## Consequences
195242

@@ -198,18 +245,19 @@ exist in production is **unmeasured** — the loud state is what will reveal the
198245
and that is its purpose. It is user-visible, so the objectui half ships with a
199246
changeset banner saying so. A repo-wide census over `examples/`, `packages/`,
200247
`content/` and `skills/` at `03b7b8187` found **zero** field-rule predicates of
201-
either refused spelling, against two live lit controls (15 files carrying
248+
either spelling D1 refuses, against two live lit controls (15 files carrying
202249
non-blank tagged-template predicates, 14 carrying envelope-form ones) — so there
203-
is nothing in this repository to rewrite.
250+
is nothing in this repository for D1's landing to rewrite.
204251

205252
**One stored-row edge is named rather than asserted.**
206253
`applyConversionsToStoredItem` **is** applied to `object` (only `flow` is
207254
skipped), but no conversion repairs either refused spelling, so a stored row
208-
carrying one now meets a strict schema at whichever seam parses it. Which seam
209-
that is, and whether it degrades to a warn or refuses the object, was **not
210-
measured** on this card. It is recorded here as the open edge, and in the D3
211-
entry's acceptance criteria as "read the boot log for the object by name rather
212-
than expecting a specific message".
255+
carrying one meets a strict schema at whichever seam parses it once the refusal
256+
lands. Which seam that is, and whether it degrades to a warn or refuses the
257+
object, was **not measured** on this card. It is recorded here as the open edge,
258+
and it belongs in the acceptance criteria of the ADR-0087 entry that carries the
259+
refusal — #18638's, under batch #122 item 2 — as "read the boot log for the
260+
object by name rather than expecting a specific message".
213261

214262
**ADR-0089 is extended, not reversed.** That record unified the `*When` family
215263
under one name and is untouched by this one; this record decides what the family

0 commit comments

Comments
 (0)