Skip to content

Commit 6120437

Browse files
committed
docs(adr): record ADR-0136 — predicate fault semantics are contract
The ADR-0087 D3 semantic entry for the D1 narrowing, the pin test for all three field-rule slots, and the two ADR anchors. ADR-0089 gains a pointer: it unified the `*When` family's NAME, this record decides what the family does when it cannot run. Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
1 parent 059b422 commit 6120437

8 files changed

Lines changed: 587 additions & 3 deletions

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@
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.
18+
19+
---
20+
1121
## TL;DR
1222

1323
One concept — *"show this only when the CEL predicate is TRUE"* — is spelled three

‎docs/adr/0136-predicate-fault-semantics-are-contract.md‎

Lines changed: 243 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The `FieldSchema` field-rule triad is EVALUATED (#17778, ADR-0136 D1) — all
5+
* three of `visibleWhen` / `readonlyWhen` / `requiredWhen` compose
6+
* `PredicateInputSchema`, which is now the evaluated rule, so an envelope the
7+
* engine cannot evaluate is refused at authoring instead of silently doing
8+
* nothing at evaluation time.
9+
*
10+
* Before this the triad was `ExpressionInputSchema` — the persistence contract,
11+
* `source` OR `ast` — and all three slots resolved both refused spellings to a
12+
* NO-OP, each by its own mechanism (measured at each end on `03b7b8187`):
13+
* `visibleWhen` is never evaluated server-side at the field level at all
14+
* (`rule-validator.ts`'s `ConditionalFieldDef` has no such member);
15+
* `isReadonlyWhenLocked` returns LOCKED only for an unbound-ROOT fault and
16+
* logs `change allowed through` + returns `false` for every other fault, which
17+
* is the arm an unevaluable envelope takes; `requiredWhen` logs and SKIPS the
18+
* check. So one unauthored predicate made a form show more, lock less and
19+
* demand less at once, with nothing saying the rule had not run.
20+
*
21+
* Reproduction pins, one per spelling per slot, each asserting the issue's
22+
* `code`, `path` and message — never `success === false` alone, which an
23+
* unrelated refusal would satisfy just as well. Two preservation controls
24+
* carry the other half: the persistence contract still ACCEPTS both refused
25+
* shapes (it was deliberately not narrowed), and a real predicate parses to
26+
* byte-identically what it parsed to before.
27+
*/
28+
29+
import { describe, expect, it } from 'vitest';
30+
31+
import {
32+
EVALUATED_EXPRESSION_SOURCE_REQUIRED,
33+
ExpressionInputSchema,
34+
ExpressionSchema,
35+
} from '../shared/expression.zod.js';
36+
import { FieldSchema } from './field.zod.js';
37+
38+
const AST_ONLY = { dialect: 'cel', ast: { kind: 'const', value: true } };
39+
const BLANK_SOURCE = { dialect: 'cel', source: ' ' };
40+
const BLANK_BARE = ' ';
41+
const GOOD = { dialect: 'cel', source: "record.status == 'paid'" };
42+
43+
const FIELD = { name: 'amount', label: 'Amount', type: 'currency' } as const;
44+
45+
const SLOTS = ['visibleWhen', 'readonlyWhen', 'requiredWhen'] as const;
46+
47+
function issuesOf(slot: string, value: unknown) {
48+
const result = FieldSchema.safeParse({ ...FIELD, [slot]: value });
49+
return result.success
50+
? []
51+
: result.error.issues.map((i) => ({
52+
code: i.code,
53+
path: i.path.map(String).join('.'),
54+
message: i.message,
55+
}));
56+
}
57+
58+
describe('FieldSchema field-rule triad — an evaluated slot requires a non-blank `source` (#17778)', () => {
59+
for (const slot of SLOTS) {
60+
describe(`\`${slot}\``, () => {
61+
it('REFUSES an `ast`-only envelope with the published sentence at the slot', () => {
62+
// Both arms of the input union abort on this shape (the envelope arm's
63+
// missing `source` is an aborting `invalid_type`), so it surfaces as
64+
// the union's own issue at the SLOT, worded by the union's error map.
65+
expect(issuesOf(slot, AST_ONLY)).toEqual([
66+
{ code: 'invalid_union', path: slot, message: EVALUATED_EXPRESSION_SOURCE_REQUIRED },
67+
]);
68+
});
69+
70+
it('REFUSES a blank `source` INSIDE an envelope, at `source`', () => {
71+
// The one shape the envelope arm refuses WITHOUT aborting (a `custom`
72+
// refine), so it surfaces from the arm itself, one level deeper.
73+
expect(issuesOf(slot, BLANK_SOURCE)).toEqual([
74+
{
75+
code: 'custom',
76+
path: `${slot}.source`,
77+
message: EVALUATED_EXPRESSION_SOURCE_REQUIRED,
78+
},
79+
]);
80+
});
81+
82+
it('REFUSES a blank bare-string shorthand at the slot', () => {
83+
expect(issuesOf(slot, BLANK_BARE)).toEqual([
84+
{ code: 'invalid_union', path: slot, message: EVALUATED_EXPRESSION_SOURCE_REQUIRED },
85+
]);
86+
});
87+
88+
it('PRESERVES a real predicate — parses, and to the same envelope as before', () => {
89+
const result = FieldSchema.safeParse({ ...FIELD, [slot]: GOOD });
90+
expect(result.success).toBe(true);
91+
expect((result as { success: true; data: Record<string, unknown> }).data[slot]).toEqual(GOOD);
92+
});
93+
94+
it('PRESERVES the bare-string shorthand, normalized to a `cel` envelope', () => {
95+
const result = FieldSchema.safeParse({ ...FIELD, [slot]: "record.status == 'paid'" });
96+
expect(result.success).toBe(true);
97+
expect((result as { success: true; data: Record<string, unknown> }).data[slot]).toEqual(GOOD);
98+
});
99+
100+
it('PRESERVES an `ast` BESIDE a string `source`', () => {
101+
expect(FieldSchema.safeParse({ ...FIELD, [slot]: { ...GOOD, ast: { kind: 'const' } } }).success)
102+
.toBe(true);
103+
});
104+
105+
it('PRESERVES absence — "no rule" is not a malformed rule', () => {
106+
expect(FieldSchema.safeParse(FIELD).success).toBe(true);
107+
});
108+
});
109+
}
110+
111+
it('CONTROL: the persistence contract was NOT narrowed and still accepts both shapes', () => {
112+
// If this control ever goes red, the narrowing has leaked out of the
113+
// evaluated slots and into the schema that only PERSISTS an envelope —
114+
// which ADR-0136 D1 explicitly declines to do.
115+
expect(ExpressionSchema.safeParse(AST_ONLY).success).toBe(true);
116+
expect(ExpressionInputSchema.safeParse(AST_ONLY).success).toBe(true);
117+
expect(ExpressionSchema.safeParse(BLANK_SOURCE).success).toBe(true);
118+
expect(ExpressionInputSchema.safeParse(BLANK_SOURCE).success).toBe(true);
119+
});
120+
121+
it('CONTROL: the per-OPTION `visibleWhen` is OUT of scope and still accepts an `ast`-only envelope', () => {
122+
// ADR-0136's scope boundary: the option-level predicate, the inline-column
123+
// pair and every view/page/action gate still compose
124+
// `ExpressionInputSchema`. This pin is what makes that boundary a measured
125+
// fact rather than a claim in prose — when the gate slots are converted,
126+
// this expectation is the one that must be updated deliberately.
127+
const result = FieldSchema.safeParse({
128+
name: 'tier',
129+
label: 'Tier',
130+
type: 'select',
131+
options: [{ label: 'Gold', value: 'gold', visibleWhen: AST_ONLY }],
132+
});
133+
expect(result.success).toBe(true);
134+
});
135+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// No backticks in `surface` — build-upgrade-guide.ts renders it inside a code
6+
// span already, and a nested backtick would close it.
7+
export const entry: SemanticMigration = {
8+
id: 'field-rule-predicate-evaluated-slot-source-required',
9+
surface:
10+
'a field-rule predicate, all three slots of the triad on FieldSchema — visibleWhen, '
11+
+ 'readonlyWhen and requiredWhen — and with them the PredicateSchema / PredicateInputSchema '
12+
+ 'public exports every other declaration site composes, authored either as an expression '
13+
+ 'envelope carrying only ast ({ dialect: \'cel\', ast: … } with no source), or with a source '
14+
+ 'that is blank after trimming, through the envelope key '
15+
+ '({ dialect: \'cel\', source: \' \' }) or the bare-string shorthand for it '
16+
+ '(visibleWhen: \' \'). Reachable wherever an object is authored or stored: '
17+
+ 'defineStack({ objects }) sources, an exported stack passed to objectstack validate, a '
18+
+ 'POST /objects body, and an object row already sitting in sys_metadata',
19+
replacement:
20+
'a non-blank `source` — `{ dialect: \'cel\', source: \'record.status == \\\'paid\\\'\' }`, or the '
21+
+ 'bare string, or the `P` tagged template the examples use — if the rule was meant to gate; '
22+
+ 'or REMOVE the key entirely if the field was meant to carry no rule. ⚠️ Unlike the '
23+
+ 'flow-edge entry this one otherwise mirrors, removal here is BEHAVIOUR-PRESERVING and is a '
24+
+ 'safe default: all three slots resolve BOTH refused spellings to a no-op today (measured, '
25+
+ 'see `reason`), so deleting the key preserves exactly what the runtime already did. What '
26+
+ 'removal does NOT preserve is the author\'s INTENT — the rule was written to gate '
27+
+ 'something, and a removal records that it never did. Authoring the `source` is the '
28+
+ 'repair; removal is the honest way to say the rule was abandoned. An `ast` BESIDE a string '
29+
+ '`source` is untouched and stays admitted everywhere',
30+
reason:
31+
'Card #17778, on the maintainer ruling for objectui#8069 (decision batch #119 item 3, '
32+
+ '2026-09-12: 「同意」 to A, Q2 yes, Q3 yes) and the standing ruling '
33+
+ '「我们的项目以objectstack 协议为准…协议不正确的应该先修改协议。」. ADR-0136 D1: a predicate is an '
34+
+ 'EVALUATED slot by definition, so `PredicateSchema` / `PredicateInputSchema` now compose '
35+
+ '`EvaluatedExpressionSchema` / `EvaluatedExpressionInputSchema` instead of the PERSISTENCE '
36+
+ 'contract `ExpressionSchema` / `ExpressionInputSchema`, and the field-rule triad binds '
37+
+ 'them. The CEL engine evaluates `source` alone (`cel-engine.ts` `evaluate`: "AST-only '
38+
+ 'evaluation not yet supported; persist `source`"), so both refused spellings parsed, '
39+
+ 'registered, passed `objectstack validate`, and then produced a rule that silently did '
40+
+ 'nothing. ⚠️ The three no-op directions are NOT the same mechanism, and each was measured '
41+
+ 'at its own end rather than inferred from the others: `visibleWhen` is never evaluated '
42+
+ 'server-side at the FIELD level at all (`rule-validator.ts`\'s `ConditionalFieldDef` has no '
43+
+ 'such member) and the renderer falls back to VISIBLE where no host publishes a predicate '
44+
+ 'scope; `readonlyWhen` reaches `isReadonlyWhenLocked`, whose unbound-ROOT arm returns '
45+
+ 'LOCKED but whose every-other-fault arm logs "change allowed through" and returns false, '
46+
+ 'and an unevaluable envelope takes that second arm — the declared lock is silently waived; '
47+
+ '`requiredWhen` logs and SKIPS the check. So one misspelled or unauthored predicate '
48+
+ 'produced a form that showed more, locked less and demanded less at once, with no state '
49+
+ 'that said "this rule did not run" — which is the composite objectui#8069 measured and '
50+
+ 'this entry\'s ruling closes. ⚠️ No D2 conversion is possible, and that is why this needs '
51+
+ 'a D3 entry rather than none: an `ast`-only envelope carries no `source` to derive one '
52+
+ 'from (lowering an AST to surface syntax is the compiler direction the platform does not '
53+
+ 'run), and a blank `source` names no predicate to reconstruct. Choosing between "author '
54+
+ 'the rule" and "drop the rule" is an intent question only the author can answer, which is '
55+
+ 'what this entry delegates. ⚠️ Scope of the RUNTIME half, stated so it is not read wider '
56+
+ 'than it is: ADR-0136 D2 (a faulting field-rule predicate refuses the SUBMIT, naming the '
57+
+ 'field and the rule) and D3 (visibility stays fail-OPEN at RENDER) are consequences '
58+
+ 'CONSUMERS deliver — `packages/spec` carries no business logic — and the renderer half '
59+
+ 'lands in objectui#8069. This entry is the AUTHORING refusal only. ⚠️ And one '
60+
+ 'stored-row consequence is deliberately left UNMEASURED rather than guessed: '
61+
+ '`applyConversionsToStoredItem` IS applied to `object` (only `flow` is skipped, '
62+
+ '`spec/src/conversions/stored.ts` and the same skip in '
63+
+ '`metadata/src/loaders/database-loader.ts` `rowToData`), but no conversion can repair '
64+
+ 'either refused spelling, so a stored row carrying one now meets a strict schema at '
65+
+ 'whichever seam parses it; which seam that is, and whether it degrades to a warn or '
66+
+ 'refuses the object, was NOT measured on this card and is named here as the open edge '
67+
+ 'rather than asserted. A repo-wide census over examples/, packages/, content/ and skills/ '
68+
+ 'at 03b7b8187 found ZERO field-rule predicates of either spelling against two live lit '
69+
+ 'controls (non-blank tagged-template predicates: 15 files; envelope-form predicates: 14 '
70+
+ 'files), so there is nothing in THIS repository to rewrite — a repo reading, which is why '
71+
+ 'the notification is registered here rather than skipped. ADR-0136, ADR-0087, ADR-0089, '
72+
+ 'ADR-0058, ADR-0124.',
73+
acceptanceCriteria:
74+
'Grep every authored field-rule predicate — all three of `visibleWhen`, `readonlyWhen` and '
75+
+ '`requiredWhen` on a FIELD (not the per-OPTION `visibleWhen`, not the inline-column pair, '
76+
+ 'and not a view/page/action gate: those slots still compose `ExpressionInputSchema` and '
77+
+ 'are unaffected by this entry) — in `defineStack({ objects })` sources, exported stacks '
78+
+ 'and `POST /objects` bodies, and every object row in `sys_metadata`, for an envelope with '
79+
+ 'no `source` key and for a `source` (or bare string) that is empty after trimming. For '
80+
+ 'each hit decide, per the `replacement` note, whether the rule was meant to gate (author '
81+
+ 'the `source`) or was abandoned (remove the key) — removal is behaviour-preserving here, '
82+
+ 'so it is a safe default, but it records that the rule never ran. Two proofs. (1) For a '
83+
+ 'stack authored in config files, `objectstack validate` is clean: it locates each offender '
84+
+ 'with the `EVALUATED_EXPRESSION_SOURCE_REQUIRED` sentence at '
85+
+ '`objects.N.fields.N.visibleWhen` (and the two siblings) — a blank `source` inside an '
86+
+ 'envelope surfaces as one issue at `source`, while an `ast`-only envelope and a blank '
87+
+ 'bare string abort both arms of the input union and surface as one `invalid_union` at the '
88+
+ 'SLOT, carrying that same sentence. There is no CLI verb that lowers a stored row back '
89+
+ 'into a config file, so this proof does not reach an object that exists only in '
90+
+ '`sys_metadata`. (2) For stored rows, boot the stack and confirm each object still loads '
91+
+ 'and its form still renders: a row carrying either spelling no longer parses, and per the '
92+
+ '`reason`\'s last note the seam that reports it was not measured on this card — so read '
93+
+ 'the boot log for the object by name rather than expecting a specific message, and treat '
94+
+ 'a missing object as this entry\'s signal. An object whose field rules all carry a '
95+
+ 'non-blank `source` parses byte-identically to before.',
96+
};

0 commit comments

Comments
 (0)