Skip to content

Commit 3d91885

Browse files
fix(lint)!: the object save door gives the build's validation-rule verdict (#22032 pass 1) (#22041)
Part of #22032 Clause-②: no (narrowing) This is pass 1 of #22032: the validation-rule predicates. Passes 2 to 4 stay fenced, and the card stays open for them: the field-rule slots, option `visibleWhen`, and the object's action predicates. ## What changes **The object save door gives the build's verdict on a validation rule's predicates.** `formulas.mdx` says "the same `validateExpression` validator backs `os build` and metadata registration". PR #22031 (#22019) made that true for formula fields and fenced every other object-borne pass off the door by name. So an object whose `validations[].condition` was `sqrt(record.amount) > 1`, or a bare `amount > 1`, still saved with a 200, while `os build` refused both at error. - **The change is in the fence, not the registry.** `runStackExpressionPasses` (`packages/lint/src/validate-expressions.ts`) no longer empties the validation-rule loop on an `object` write. On that write the rule now runs two passes, each at the build's own position in the walk: - the field-formula pass (unchanged); - the validation-rule pass. It judges each rule's `condition`, with the relationship-traversal checks, and a `conditional` rule's `when`. It also runs the #4763 null-guard gate over every predicate the rule carries, including those in the nested `then` and `otherwise` rules. - **No registry change.** The `validateStackExpressions` entry already declared `object` after PR #22031, so `runtimeAuthoringRulesFor('object')` already dispatched it. `runtime-gate.ts` is untouched. In `authoring-rules.ts` only comments move: the entry's comment and the `AuthoringRuleContext.runtimeWriteType` docblock. The latter is the one line that reaches a built `.d.ts`. - **The fence flag is renamed** from `fieldFormulasOnly` to `objectWrite`. The old name would have been false. Its docblock (`StackExpressionOptions.runtimeWriteType`) now names the two admitted passes and the three fenced ones. - **The door's verdict is the build's finding.** The door's 422 issue and `runAuthoringRules('build', …)` give the same rule (`expression-invalid`), location (`object 'fx_rule' · validation 'amount_rule'`), message and hint. The pins compare these key by key. - **No code change in `packages/metadata-protocol`.** Only its test file gains the door-level pins. ## Pins - **Lint door:** `packages/lint/src/runtime-gate.object-validation-writes.test.ts` (new, 8 tests). It covers: - LIT refusals: `sqrt(record.amount) > 1`, a bare `amount > 1`, a `conditional` rule's `when`, and the null-guard gate's reach into the nested `then` and `otherwise` predicates; - CONTROL: valid, guarded predicates, at the top level and nested, are clean at the door and at the build; - PARITY: for each refused body, the door's findings equal the build's; - the differential: a stored sibling's broken rule is not this write's to answer for. - **The fence (enumeration pin):** in `packages/lint/src/runtime-gate.object-formula-writes.test.ts`. The fenced body now carries one site for each of the three passes still fenced: a `requiredWhen`, an option `visibleWhen`, and an action `visible`. Before this PR it carried no option `visibleWhen`, so it named only two of the three. The body also carries the lifted validation-rule site. The build flags all four sites. The object door flags the lifted site alone. On an object write, `runStackExpressionPasses` returns exactly the build's own findings for the admitted passes. - **Protocol door:** a new #22032 block in `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`, through the real `saveMetaItem` and `publishMetaItem`: - (a) both card bodies are refused with a 422 `INVALID_METADATA` carrying the build's located finding, and nothing lands; - (a) the same refusal on a draft's promotion; - (b) a valid, guarded `condition` (`record.amount != null && record.amount > 100`) still saves, and the row lands; - (d) for each refused body, the door and `os build` give the same finding, compared on rule, where, path, message and hint. - **The roster comment** in `runtime-gate.object-writes.test.ts` is reworded. The roster itself does not move. ## Reverse verification (one-off, from committed HEAD `fcc1ae0c`) - **What was mutated.** The fence was put back on the validation-rule loop with `scripts/ablation-replace.mjs`: `for (const rule of recordsOf(validations))` became `for (const rule of objectWrite ? [] : recordsOf(validations))`. The anchor was hit once, 1 → 0, and the blob went `55ee216f3d48` → `61fe6c01789b`. - **Rebuild and dist proof.** `@objectstack/lint` was rebuilt. `ablation-dist-preflight` found the planted marker in 4 built files. - **The lint suites (source): red as predicted, 7 failed and 10 passed.** Red: the 4 LIT tests, PARITY, and the two fence tests. Green: CONTROL, the differential, the registry test, the build-flags-each-site test, and the six #22019 formula-door tests. - **The protocol block (dist-mediated): red as predicted, 4 failed and 1 passed.** Red: (a) for both bodies, (a) on promotion, and (d). Green: (b). - **Restore.** The tool restored the file: blob `55ee216f3d48` equals HEAD, and `git diff HEAD` is empty. Lint was rebuilt, and `--absent` found the marker gone from all 14 built files, with a clean tree. Both suites went green again: lint 17 passed, and the protocol file 79 passed. ## Measurements - **Corpus first (H4): the stop condition was not met.** Every object this tree ships was judged by the build's validation-rule pass before the door changed. That is every `*.object.ts` under `packages/**` and `examples/**`, plus the two `app-multi-package` sub-stacks: 118 objects in 17 groups. - It was judged at the raw shape and at the `ObjectSchema.parse` shape. - After the change it was judged again by the door's own function (`runRuntimeAuthoringRules`, type `object`, with the object's own group as the context). - 21 rules carry 13 predicates on 10 objects: - examples: 11 predicates on 7 objects (`app-crm` 3 on 2, `app-showcase` 6 on 4, `app-todo` 2 on 1); - platform: `plugin-security` 2 on 2 (`sys_position`, `sys_user_position`), plus one rule on `sys_user` that carries no predicate. - Result: 0 build errors and 0 build warnings; 0 door errors and 0 door advisories. - The card's two bodies, run as a positive control in the same harness, gave 2 build errors and 2 door errors. - **H1: confirmed.** The fence is `StackExpressionOptions.runtimeWriteType` (`validate-expressions.ts:1178`). It is consulted in `runStackExpressionPasses` (`:1222` at base, `:1233` at head). At base the validation-rule loop read `fieldFormulasOnly ? [] : recordsOf(validations)` (`:1859`). The lift removes that guard. Passes 2 to 4 keep theirs: the field walk's early `continue`, and the action loop. - **H2: confirmed. No registry change.** The entry declares `runtimeTypes: ['flow', 'action', 'hook', 'object']` (`authoring-rules.ts:602` at base). `runtimeAuthoringRulesFor('object')` (`runtime-gate.ts:550`) already dispatched it. - **H3: confirmed.** The door's verdict equals the build's finding. This is pinned key by key through the real save path for both card bodies. At the lint level it is pinned for four bodies, including `when` and the nested null-guard sites. ## Clause-② (measured) - **Accept set: narrowing.** An object write in publish mode answered 200 for a validation rule whose predicate the validator refuses. It now answers 422. This covers the active save, the draft promotion and the package draft publish, which share the one gate. - **Built entry declarations.** In `@objectstack/lint`, one doc comment changes (`AuthoringRuleContext.runtimeWriteType`). `StackExpressionOptions` and `runStackExpressionPasses` are not in the built declarations. No exported signature moves. - **Changeset.** `.changeset/22032-object-save-door-validation-predicates.md` covers `@objectstack/lint` and `@objectstack/metadata-protocol`: `minor`, BREAKING, `fix(lint)!`. It gives the remedy, and its ADR-0087 disposition is `not-required (no-migration-prescription)`. It follows #22019's changeset form. ## Tests and gates (all at `fcc1ae0c`) - **Package tests:** - `@objectstack/lint`: 121 files, 5646 tests passed. - `@objectstack/metadata-protocol`: 219 files passed and 3 skipped; 28055 tests passed and 19 skipped. - `typecheck` passed for both. The lint run includes its test-typecheck. `--listFiles` shows both new and edited test files inside the tsc program. - **Consumer readings.** These are the files in the door's consumer radius that carry validation-rule fixtures or drive the object save door, run against a rebuilt closure: - `@objectstack/rest`: `meta-object-extension-property-classes`, `meta-object-materialization-agreement`, `meta-object-overlay-extension-fold`, `meta-object-owd-gate` and `meta-publish-package-scope`. 5 files, 71 tests passed. - `@objectstack/objectql`: `save-meta-response-conformance`, `publish-meta-response-conformance` and `plugin.integration`. 3 files, 67 tests passed. - No other test fixture saves a validation predicate through the publish door. That was found by grepping every test with `validations` against the door's entry points. - **Gates.** - `dispatch-gates.mjs --commands` was derived at this head: 63 commands, all exit 0. `--ran` reconciles: 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN. - `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET. It passed after a full turbo build. - `check:type-check-debt` was cut off by the batch's time cap and passed when re-run alone (92s). - The artifact-roster block (34 non-self-test families) and the 11 declared wide-population families were also run: 45 commands, all exit 0. - **ESLint, narrowed to the 6 touched TypeScript files** (`--no-inline-config`): 6 files, 0 errors and 0 warnings. - The count is read from `--format json`. - Each file is matched by `eslint.config.mjs` (`--print-config`), and no file was reported as ignored. - `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. ## Acceptance notes - **A gap that both doors share.** The build's validation-rule pass judges only the top-level `condition` and `when` of each rule. A nested `then` or `otherwise` rule's `condition` gets the null-guard gate and nothing else. - Measured through the real `saveMetaItem` at this head: a `conditional` rule whose `then.condition` is `sqrt(record.amount) > 1`, and whose `otherwise.condition` is a bare `amont > 1`, saves with a 200, and the row lands. The same predicate at the top level is refused with a 422. - The door now mirrors the build exactly, so this is the build's gap. It is outside this card. It is reported on the card for the seat to file. - **Docs.** `formulas.mdx` was not edited. Its promise now holds at the object save door for formula fields and validation-rule predicates. The "Build-time validation" section could name the object save door: that is a docs addition, not a false line. - **Contract review.** Triage's grade asks for a contract review for each pass. It is not attached here. It is the seat's, from an isolated subagent at the contract-review tier. --- _Generated by [Claude Code](https://claude.ai/code/session_01GV6oYwgc1kWiUCb1YaprQ7)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d4680d2 commit 3d91885

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)