Skip to content

Commit 00bf19b

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21898-builtin-node-config-values-judged
Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
2 parents 42ce99c + 3d91885 commit 00bf19b

7 files changed

Lines changed: 450 additions & 58 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/lint": minor
3+
"@objectstack/metadata-protocol": minor
4+
---
5+
6+
fix(lint)!: the object save door refuses a validation rule whose predicate `os build` refuses (#22032)
7+
8+
Clause-②: no (narrowing)
9+
10+
`formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For a validation rule's predicates it did not, at the object save door. A rule whose `condition` called an unregistered function, such as `sqrt(record.amount) > 1`, or read a bare field, such as `amount > 1`, was refused by `os build` at error, but `PUT /api/v1/meta/object/:name` answered 200 and stored it. The rule then faulted on every write it judged.
11+
12+
The runtime publish gate now runs the build's validation-rule check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields alone. On an object write it now also runs its validation-rule pass: each `validations[]` rule's `condition` and a `conditional` rule's `when`, plus the null-guard check over every predicate the rule carries, its nested `then` and `otherwise` rules included. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · validation 'RULE'`, or `… validation rule 'RULE' then → 'CHILD'` for a nested predicate), message and hint.
13+
14+
**BREAKING — what moves for consumers.**
15+
16+
- An object write in publish mode answered 200 for a validation rule whose predicate the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that rule. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`).
17+
- The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), a syntax error, an ordering or arithmetic operator applied to a nullable field with no `!= null` guard (`has()` is no guard here), and the other errors in the build's validation-rule check. Its warnings now ride the save response as advisories.
18+
19+
**Remedy.** Fix the predicate: the message names the unknown function or field, or the unguarded operand, and the position, as `os build` already requires. Qualify field reads as `record.FIELD`, use one of the functions `introspectScope` lists, and guard a nullable operand with `record.FIELD != null && …`. Saving it as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged.
20+
21+
**Unchanged.**
22+
23+
- Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved. At that save the gate judges it, because the differential compares the write against the stored universe without its own stored row.
24+
- The other expressions an object carries are still not judged at this door: the field-rule slots (`requiredWhen`, `readonlyWhen`, `conditionalRequired`, `visibleWhen`), option `visibleWhen`, and the object's own action predicates. `os build` judges them, and the door does not, as before.
25+
- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write.
26+
- Measured before crossing: every validation rule this repository ships has 0 refusals and 0 advisories, at the build and at the door. That is 21 rules carrying 13 predicates on 10 objects: examples 11 predicates on 7 objects, and the platform objects 2 on 3 (one rule on `sys_user` carries no predicate).
27+
- No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`.
28+
29+
<!-- adr-0087: not-required (no-migration-prescription) a refusal at the object save door of a validation-rule predicate the published validator already refuses at `os build`: no authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. A stored object whose rule predicate the validator refuses keeps loading until it is next saved, and the repair is the author's edit of the predicate, which no ledger entry can derive. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id covers this door (not already-registered); and the change is a door verdict, not a declaration (not runtime-interface-only or type-surface-only). -->

‎packages/lint/src/authoring-rules.ts‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -309,9 +309,10 @@ export interface AuthoringRuleContext {
309309
*
310310
* [#22019] One other rule reads it, on that argument: `validateStackExpressions`
311311
* is one entry over several PASSES, and an `object` write is admitted for its
312-
* field-formula pass alone (`runStackExpressionPasses`, `StackExpressionOptions`). The
313-
* entry-level `runtimeTypes` can say that an object write reaches the rule; it
314-
* cannot say which of the rule's passes judge that write.
312+
* field-formula pass and (#22032) its validation-rule pass alone
313+
* (`runStackExpressionPasses`, `StackExpressionOptions`). The entry-level
314+
* `runtimeTypes` can say that an object write reaches the rule; it cannot say
315+
* which of the rule's passes judge that write.
315316
*/
316317
runtimeWriteType?: string;
317318
/**
@@ -588,16 +589,30 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
588589
// NARROW by construction, not by snapshot shape: `ctx.runtimeWriteType`
589590
// reaches `runStackExpressionPasses` — the body `validateStackExpressions`
590591
// runs, whose public signature is unchanged — which on an object write runs
591-
// the field-formula pass and fences every other object-borne expression
592+
// the passes admitted there and fences every other object-borne expression
592593
// pass off by name (`StackExpressionOptions.runtimeWriteType`) — each of
593-
// those is a crossing of its own, not a rider on this one.
594+
// those is a crossing of its own, not a rider on another.
594595
//
595596
// MEASURED over the stored corpus at the door's own snapshot shape before
596597
// crossing: every formula field the repository ships — 29 fields on 28
597598
// objects (examples: app-crm 4 on 3, app-showcase 2 on 2, app-todo 1 on 1,
598599
// app-multi-package none; platform `display_title` formulas: 22 on 22) →
599600
// 0 differential errors and 0 advisories, against 1 refusal for the card's
600601
// own `sqrt(record.amount)` body under the same harness.
602+
//
603+
// [#22032, pass 1] The validation-rule pass joins the object door: every
604+
// `validations[]` `condition` and `when`, with the null-guard gate over
605+
// the nested `then` / `otherwise` branches — the same sentence of
606+
// `formulas.mdx`, and the same gap (`sqrt(record.amount) > 1` and a bare
607+
// `amount > 1` saved with a 200). No entry-level change: `object` was
608+
// already declared above. MEASURED first, at both the raw and the parsed
609+
// shape: every validation rule the repository ships — 21 rules carrying 13
610+
// predicates on 10 objects (examples: app-crm 3 on 2, app-showcase 6 on 4,
611+
// app-todo 2 on 1; platform: plugin-security 2 on 2, and one
612+
// predicate-less rule on `sys_user`) → 0 build errors and 0 warnings for
613+
// the pass, and 0 door errors and 0 advisories at the door's own snapshot
614+
// shape, against 2 refusals at each for the card's two bodies in the same
615+
// harness.
601616
surfaces: CLI_AND_RUNTIME,
602617
runtimeTypes: ['flow', 'action', 'hook', 'object'],
603618
run: (stack, ctx) =>

‎packages/lint/src/runtime-gate.object-formula-writes.test.ts‎

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@
1818
*
1919
* `object` joins `runtimeTypes`, and the gate's `runtimeWriteType` reaches the
2020
* rule (`runStackExpressionPasses`), which on an object write runs the
21-
* field-formula pass alone. Every other object-borne pass the build runs —
22-
* validation-rule predicates, the field-rule slots, option `visibleWhen`, the
23-
* object's own action predicates — is FENCED off this door by name, and the
24-
* fence is pinned below with the build still flagging the same body, so a
25-
* later widening moves that line consciously rather than by drift.
21+
* field-formula pass — and, since #22032's pass 1, the validation-rule pass
22+
* (its pins: `runtime-gate.object-validation-writes.test.ts`). Every other
23+
* object-borne pass the build runs — the field-rule slots, option
24+
* `visibleWhen`, the object's own action predicates — is FENCED off this door
25+
* by name, and the fence is pinned below with the build still flagging the
26+
* same body, so a later widening moves that line consciously rather than by
27+
* drift.
2628
*
2729
* The protocol-level half — the same verdict through the real `saveMetaItem`
2830
* and `publishMetaItem`, and the door/build equality of the finding — is
@@ -112,10 +114,13 @@ describe('#22019 — the object door dispatches the build\'s expression rule', (
112114

113115
describe('#22019 — the fence: every other object-borne expression pass stays off this door', () => {
114116
/**
115-
* One body carrying a fault in each fenced pass, and a CLEAN formula. The
116-
* build flags every one of them; the object door flags none. Each fault is
117-
* one the build refuses at `error`, so "the door is silent" cannot be read
118-
* as "there was nothing to say".
117+
* One body carrying a fault in each FENCED pass — #22032's passes 2 to 4,
118+
* one site each: a field-rule slot (`requiredWhen`), an option's
119+
* `visibleWhen`, an object action's `visible` — beside a fault in the
120+
* validation-rule pass (#22032 pass 1, LIFTED) and a CLEAN formula. The
121+
* build flags every fault; the object door flags the lifted pass's alone.
122+
* Each fault is one the build refuses at `error`, so "the door is silent on
123+
* a fenced site" cannot be read as "there was nothing to say".
119124
*/
120125
const fenced = () => fxSqrt('floor(record.amount)', {
121126
validations: [
@@ -127,28 +132,40 @@ describe('#22019 — the fence: every other object-borne expression pass stays o
127132
});
128133
const withFieldRule = () => {
129134
const body = fenced();
130-
(body.fields as Record<string, unknown>).name = {
131-
type: 'text', label: 'Name', requiredWhen: 'amount > 1',
135+
const fields = body.fields as Record<string, unknown>;
136+
fields.name = { type: 'text', label: 'Name', requiredWhen: 'amount > 1' };
137+
fields.tier = {
138+
type: 'select',
139+
label: 'Tier',
140+
options: [{ label: 'Gold', value: 'gold', visibleWhen: 'amount > 1' }],
132141
};
133142
return body;
134143
};
135-
136-
it('the build (no `runtimeWriteType`) still flags each fenced site', () => {
144+
/** The four fenced sites (passes 2–4) and the lifted one (pass 1), by the build's `where`. */
145+
const FENCED_SITES = [
146+
"object 'fx_sqrt' · field 'name' requiredWhen",
147+
"object 'fx_sqrt' · field 'tier' option 'gold' visibleWhen",
148+
"object 'fx_sqrt' · action 'fx_close' visible",
149+
];
150+
const LIFTED_SITE = "object 'fx_sqrt' · validation 'amount_root'";
151+
152+
it('the build (no `runtimeWriteType`) still flags each fenced site, and the lifted one', () => {
137153
const wheres = validateStackExpressions({ objects: [withFieldRule()] })
138154
.filter((i) => (i.severity ?? 'error') === 'error')
139155
.map((i) => i.where);
140156

141-
expect(wheres.some((w) => w.includes("validation 'amount_root'")), dump(wheres)).toBe(true);
142-
expect(wheres.some((w) => w.includes("field 'name' requiredWhen")), dump(wheres)).toBe(true);
143-
expect(wheres.some((w) => w.includes("action 'fx_close'")), dump(wheres)).toBe(true);
157+
for (const site of [...FENCED_SITES, LIFTED_SITE]) {
158+
expect(wheres.includes(site), `${site}\n${dump(wheres)}`).toBe(true);
159+
}
144160
expect(wheres.some((w) => w === WHERE), 'the clean formula must not be flagged').toBe(false);
145161
});
146162

147-
it('the object door flags none of them — only a formula field\'s `expression` is judged there', () => {
163+
it('the object door flags none of the fenced sites — only the formula and validation-rule passes judge there', () => {
148164
const result = gateObject(withFieldRule());
149165

150166
expect(result.rulesRun).toContain('validateStackExpressions');
151-
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
167+
// [#22032 pass 1] The lifted pass's finding, and nothing else.
168+
expect(expressionFindings(result.errors).map((f) => f.where), dump(result)).toEqual([LIFTED_SITE]);
152169
expect(expressionFindings(result.advisories), dump(result)).toEqual([]);
153170
});
154171

@@ -157,8 +174,12 @@ describe('#22019 — the fence: every other object-borne expression pass stays o
157174
// option narrows ONLY on `object`, so nothing about the three existing
158175
// doors moves.
159176
const stack = { objects: [withFieldRule()] };
160-
expect(runStackExpressionPasses(stack, { runtimeWriteType: 'flow' })).toEqual(validateStackExpressions(stack));
161-
// And the object pass set is a strict subset of what the build reports.
162-
expect(runStackExpressionPasses(stack, { runtimeWriteType: 'object' })).toEqual([]);
177+
const all = validateStackExpressions(stack);
178+
expect(runStackExpressionPasses(stack, { runtimeWriteType: 'flow' })).toEqual(all);
179+
// And the object pass set is the build's own findings on the admitted
180+
// passes — a strict subset, in the build's order, none from a fenced site.
181+
const onObjectWrite = runStackExpressionPasses(stack, { runtimeWriteType: 'object' });
182+
expect(onObjectWrite.map((i) => i.where)).toEqual([LIFTED_SITE]);
183+
expect(onObjectWrite).toEqual(all.filter((i) => i.where === LIFTED_SITE || i.where === WHERE));
163184
});
164185
});

0 commit comments

Comments
 (0)