Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/33147-validate-expression-flow-condition-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'@objectstack/mcp': patch
'@objectstack/lint': patch
'@object-ui/console': patch
---

fix(mcp): `validate_expression` gives the runtime publish gate's verdict at a `flow_condition`, and judges a formula-site expression as the field it is authored on

Clause-②: yes (narrowing) — the MCP `validate_expression` tool now answers `ok: false` (or a warning) at a `flow_condition`, and for a named stored or formula field at a `formula` site, where the publish gate already refuses (or warns); and it accepts two new optional inputs, `startCondition` and `field`. The gate's acceptance set does not change.
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) the narrowing is in an advisory tool's verdict, never in what an author may write: every expression it now refuses, the publish gate already refused, so no stored metadata and no consumer code has anything to rewrite; the two inputs are optional and an existing call answers as before wherever the gate accepts -->

**BREAKING** verdict narrowing of the MCP `validate_expression` tool (`@objectstack/mcp`), at `site: 'flow_condition'` and, given the new `field`, at `site: 'formula'`.

At a flow condition the tool ran the shared expression validator and nothing else, so it answered `ok: true` for conditions the publish gate then refused when the flow was saved. It now also runs the gate's own passes there, through `judgeAuthoredSiteExpression` (`@objectstack/lint`), as the runtime publish gate judges a flow write, the door a flow saved through MCP or Studio meets:

- an error on a record-triggered flow: a nullable field read as `record.FIELD` or `previous.FIELD` without a null guard (`record.days > 3` with `days` neither required nor defaulted), the run failing at the condition;
- a warning on any flow bound to an object: a read of an injected system column an external object has no storage behind (`record.owner_id` on an ADR-0015 `external` object).

At a flow condition the verdict is that door's, not `os validate`'s: `os validate` also judges a bare name's null guard (`days > 3`) and a root the run does not bind, over the whole flow's variables, node ids and entrances, which the tool is not handed. The door itself also warns when a field is read bare under a name a flow variable shares; the tool is never handed the flow's variables, so it cannot give that warning. Write `record.FIELD` to be judged here.

Two new optional inputs:

- `startCondition` (`site: 'flow_condition'` only): the flow's start condition, for an edge or `decision` branch condition after it. What it proves non-null holds downstream, as at the gate, so `record.days > 3` after a start condition `record.days != null` is accepted. Omitted, the condition is judged as the start condition itself.
- `field` (`site: 'formula'` only, a snake_case field name): the field the expression is authored on. A field the caller is served keeps its definition with the expression in place: a stored field (any type but `formula`) gets the gate's stored-expression warnings (a read of another record, the clock, the acting user or a permission), and a formula field is refused when it would close a formula cycle. Any other name, a field masked from the caller included, is judged as a new formula field, so the answer never says whether a hidden field exists. A stored field that is not declared yet gets its stored-expression warnings once it is declared.

Each finding is the gate's sentence, at the gate's severity. An expression the gate accepts answers exactly as before. A sentence that names the condition names it `flow 'this flow' · edge 'this condition' (start→end) condition`, since the tool is handed the condition and not the flow it will sit in.

- `@objectstack/mcp`: the two inputs, the flow-condition judgment (it reads the subject's served definition alone there; nothing hydrates a hop at a flow condition), the tool description and the served `SKILL.md` line.
- `@objectstack/lint`: `judgeAuthoredSiteExpression` takes `site: 'flow_condition'` with `triggerKind`, `sweepObject` and `startCondition`, and `field` at `site: 'formula'`; exports `AUTHORED_FLOW_NAME`, `AUTHORED_CONDITION_ID` and the `AuthoredFlowTrigger` type. The gate's verdicts are unchanged.
- `@object-ui/console`: the downloadable `SKILL.md` mirror carries the same line.
402 changes: 390 additions & 12 deletions packages/lint/src/authored-site-expression.test.ts

Large diffs are not rendered by default.

232 changes: 194 additions & 38 deletions packages/lint/src/authored-site-expression.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#32872] The publish gate's verdict on ONE authored expression, at the two
* [#32872] The publish gate's verdict on ONE authored expression, at the
* sites the gate judges with passes of its own beyond the shared validator: a
* validation rule's `condition` and a formula field's `expression`.
* validation rule's `condition`, a formula field's `expression` and (#33147) a
* flow's condition.
*
* The MCP `validate_expression` tool promises "the verdict is the one the
* publish gate gives that rule". It runs the shared validator
Expand All @@ -24,19 +25,42 @@
* pass added to the gate at either site is carried by construction, and
* `authored-site-expression.test.ts` lists each one so its refusal through
* this entry is pinned.
*
* [#33147] A flow condition is judged the way the runtime publish gate judges
* a flow write (`runtimeWriteType: 'flow'`), the door an MCP or Studio write
* of the flow meets: the probe is a one-flow stack whose start node carries
* the trigger the caller names, and the condition sits on its one edge. That
* door gives the null guard on a `record.` / `previous.` read and the
* unprovisioned-anchor warning. It never judges a bare name's null guard or a
* root the run does not bind: those are `os validate`'s whole-flow judgments,
* over the flow's variables, node ids and entrances. The door does warn on a
* bare read of a name a flow variable shares with a field
* (`warnShadowedFieldReads`), but that needs the flow's declared variables,
* and the caller does not hand this entry any, so the probe declares none and
* that warning never fires here. So `[]` at a flow condition is that door's
* verdict, short of the shadowed-read warning, and not `os validate`'s.
*/

import { analyzeRelationshipTraversals } from '@objectstack/formula';
import { stripInjectedSystemColumns } from '@objectstack/metadata-core';
import type { FlowTriggerKind } from '@objectstack/spec/automation';
import { referenceTargetOf } from '@objectstack/spec/data';

import { recordsOf } from './object-graph.js';
import { runStackExpressionPasses, type ExprIssue } from './validate-expressions.js';

type AnyRec = Record<string, unknown>;

/** The two sites the gate judges with passes of its own, as `validate_expression` names them. */
export type AuthoredExpressionSite = 'validation' | 'formula';
/** The sites the gate judges with passes of its own, as `validate_expression` names them. */
export type AuthoredExpressionSite = 'validation' | 'formula' | 'flow_condition';

/**
* [#33147] How the flow a condition sits on is started, as
* `resolveFlowTriggerKind` reads it off the flow, or `none` for a flow with
* no auto-launch trigger (a screen flow, a flow started by hand, an action or
* a parent flow).
*/
export type AuthoredFlowTrigger = FlowTriggerKind | 'none';

/** What {@link judgeAuthoredSiteExpression} judges. */
export interface AuthoredSiteExpression {
Expand All @@ -47,6 +71,31 @@ export interface AuthoredSiteExpression {
expression: string;
/** site `validation` only: the rule's `severity` (the spec default is `error`). */
severity?: 'error' | 'warning' | 'info';
/**
* [#33147] site `formula` only: the name of the field the expression is
* authored on. A field the subject's definition declares keeps its
* definition (its type above all) with this expression in place, so a
* stored field (any type but `formula`) is judged as stored and a formula
* is judged on the object's formula graph under its own name. A name the
* definition does not declare is judged as a new `formula` field of that
* name. Absent: a new formula field no authored formula can read.
*/
field?: string;
/** [#33147] site `flow_condition` only: how the flow is started. Default: `record_change`. */
triggerKind?: AuthoredFlowTrigger;
/**
* [#33147] site `flow_condition` with `triggerKind` `time_relative` only:
* the flow's `timeRelative.object`. Default: `objectName`.
*/
sweepObject?: string;
/**
* [#33147] site `flow_condition` only: the flow's start condition, when the
* judged condition is an edge or a `decision` branch after it. The gate
* presumes what the start condition proves at every condition downstream,
* so a guard written there holds here. Absent: the judged condition is the
* start condition itself, or follows none (the gate answers both alike).
*/
startCondition?: string;
/**
* The object definitions as the registry serves them: the subject's, and
* those of the objects its references name that the expression reads
Expand All @@ -72,11 +121,81 @@ export interface AuthoredSiteExpression {
export const AUTHORED_RULE_NAME = 'this rule';

/**
* The name the judged formula field carries. No sentence of the formula
* site names it; it is spelled so that no authored field can be it.
* The name the judged formula field carries when the caller names no field.
* No sentence of the formula site names it; it is spelled so that no authored
* field can be it.
*/
export const AUTHORED_FORMULA_FIELD_NAME = '__validate_expression__';

/**
* [#33147] The names the probe flow carries. The gate's flow sentences name
* the slot they judge (`flow 'NAME' · edge 'ID' (start→end) condition`), so
* they read as the condition being authored.
*/
export const AUTHORED_FLOW_NAME = 'this flow';
export const AUTHORED_CONDITION_ID = 'this condition';

/** [#33147] Where the probe flow's one edge sits, as the gate's findings spell its key path. */
const AUTHORED_CONDITION_PATH = 'flows[0].edges[0].condition';

/**
* [#33147] The start node's trigger for each kind, spelled as
* `resolveFlowTriggerKind` reads it (`authored-site-expression.test.ts` reads
* each one back), with the flow `type` it needs.
*/
function probeTrigger(
triggerKind: AuthoredFlowTrigger,
objectName: string,
sweepObject: string | undefined,
): { type: string; config: AnyRec } {
switch (triggerKind) {
case 'record_change':
return { type: 'autolaunched', config: { triggerType: 'record-after-update' } };
case 'time_relative':
return { type: 'autolaunched', config: { timeRelative: { object: sweepObject ?? objectName } } };
case 'schedule':
return { type: 'schedule', config: { schedule: '0 * * * *' } };
case 'api':
return { type: 'api', config: {} };
case 'none':
return { type: 'autolaunched', config: {} };
}
}

/**
* [#33147] The flow the gate judges for one flow condition: a start node
* bound to `objectName` with the trigger the caller names (and its start
* condition, when given), and one edge to an end node carrying the
* condition. Exported for the read-back pin; the entry is its one caller.
*/
export function authoredFlowProbe({
objectName,
expression,
triggerKind = 'record_change',
sweepObject,
startCondition,
}: Pick<AuthoredSiteExpression, 'objectName' | 'expression' | 'triggerKind' | 'sweepObject' | 'startCondition'>): AnyRec {
const trigger = probeTrigger(triggerKind, objectName, sweepObject);
return {
name: AUTHORED_FLOW_NAME,
label: AUTHORED_FLOW_NAME,
type: trigger.type,
nodes: [
{
id: 'start',
type: 'start',
config: {
objectName,
...trigger.config,
...(startCondition !== undefined ? { condition: startCondition } : {}),
},
},
{ id: 'end', type: 'end' },
],
edges: [{ id: AUTHORED_CONDITION_ID, source: 'start', target: 'end', condition: expression }],
};
}

/**
* The objects whose definitions {@link judgeAuthoredSiteExpression} needs
* beyond the subject: the target of each reference field of `subject` that
Expand All @@ -97,51 +216,88 @@ export function authoredExpressionHopTargets(subject: AnyRec, expression: string
}

/**
* The gate's findings on the one rule or formula field described, beyond the
* shared validator's: each the gate's own pass, in the gate's words, at the
* gate's severity. Empty when `objects` carries no definition of
* `objectName`.
* The gate's findings on the one rule, formula field or flow condition
* described, beyond the shared validator's: each the gate's own pass, in the
* gate's words, at the gate's severity. Empty when `objects` carries no
* definition of `objectName`.
*/
export function judgeAuthoredSiteExpression({
site,
objectName,
expression,
severity,
field,
triggerKind,
sweepObject,
startCondition,
objects,
}: AuthoredSiteExpression): ExprIssue[] {
const authored = objects.map((o) => stripInjectedSystemColumns(o));
const served = authored.find((o) => o?.name === objectName);
if (!served) return [];
const others = authored.filter((o) => o !== served && o?.name !== objectName);
const { validations: _servedRules, ...body } = served;
const subject = site === 'validation'
? {
...body,
validations: [{
name: AUTHORED_RULE_NAME,
type: 'script',
condition: expression,
message: AUTHORED_RULE_NAME,
...(severity ? { severity } : {}),
}],
}
: { ...body, fields: withAuthoredFormulaField(body.fields, expression) };
const issues = runStackExpressionPasses(
{ objects: [subject, ...others] },
{ runtimeWriteType: 'object', sharedValidatorRunByCaller: true },
);
// Every finding of the validation-rule pass carries the judged slot's key
// path; the field-formula pass locates its findings on the field's key.
const rulePath = 'objects[0].validations[0].';
const formulaWhere = `object '${objectName}' · field '${AUTHORED_FORMULA_FIELD_NAME}' expression`;
return issues.filter((issue) =>
site === 'validation' ? issue.path?.startsWith(rulePath) === true : issue.where === formulaWhere,
);
const fieldName = field ?? AUTHORED_FORMULA_FIELD_NAME;
// The stack the gate would judge, the write type of the door that judges
// it, and which of its findings are on the judged slot.
let stack: AnyRec;
let runtimeWriteType: 'object' | 'flow';
let onSlot: (issue: ExprIssue) => boolean;
if (site === 'flow_condition') {
// [#33147] A flow write at the runtime publish gate: the walk judges the
// flow against the objects beside it, the subject among them, whose own
// rules are not what is judged here (left out, as at a validation site).
stack = {
objects: [body, ...others],
flows: [authoredFlowProbe({ objectName, expression, triggerKind, sweepObject, startCondition })],
};
runtimeWriteType = 'flow';
// Every finding on the probe's one edge carries that edge's key path.
onSlot = (issue) => issue.path === AUTHORED_CONDITION_PATH;
} else if (site === 'validation') {
const subject = {
...body,
validations: [{
name: AUTHORED_RULE_NAME,
type: 'script',
condition: expression,
message: AUTHORED_RULE_NAME,
...(severity ? { severity } : {}),
}],
};
stack = { objects: [subject, ...others] };
runtimeWriteType = 'object';
// Every finding of the validation-rule pass carries the judged slot's key path.
onSlot = (issue) => issue.path?.startsWith('objects[0].validations[0].') === true;
} else {
const subject = { ...body, fields: withAuthoredFormulaField(body.fields, fieldName, expression) };
stack = { objects: [subject, ...others] };
runtimeWriteType = 'object';
// The field-formula pass (and the formula-cycle pass after it) locate
// their findings on the field's key.
const formulaWhere = `object '${objectName}' · field '${fieldName}' expression`;
onSlot = (issue) => issue.where === formulaWhere;
}
return runStackExpressionPasses(stack, { runtimeWriteType, sharedValidatorRunByCaller: true }).filter(onSlot);
}

/**
* The served `fields`, in the shape they came in, with the judged field in
* place: [#33147] a field the body declares under `name` keeps its definition
* with `expression` written over it; any other name is added as a `formula`.
*/
function withAuthoredFormulaField(fields: unknown, name: string, expression: string): unknown {
const added = { type: 'formula', label: name, expression };
if (Array.isArray(fields)) {
const at = fields.findIndex((f) => isRecord(f) && f.name === name);
if (at < 0) return [...fields, { name, ...added }];
return fields.map((f, i) => (i === at ? { ...(f as AnyRec), expression } : f));
}
const map = isRecord(fields) ? fields : {};
const declared = Object.prototype.hasOwnProperty.call(map, name) && isRecord(map[name]) ? map[name] : undefined;
return { ...map, [name]: declared ? { ...declared, expression } : added };
}

/** The served `fields`, in the shape they came in, with the judged formula field added. */
function withAuthoredFormulaField(fields: unknown, expression: string): unknown {
const field = { type: 'formula', label: AUTHORED_FORMULA_FIELD_NAME, expression };
if (Array.isArray(fields)) return [...fields, { name: AUTHORED_FORMULA_FIELD_NAME, ...field }];
return { ...(fields && typeof fields === 'object' ? fields : {}), [AUTHORED_FORMULA_FIELD_NAME]: field };
function isRecord(v: unknown): v is AnyRec {
return !!v && typeof v === 'object' && !Array.isArray(v);
}
9 changes: 6 additions & 3 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,18 @@ export type { DatasetMeasureAggregateFinding, GroupKeyClass } from './validate-d

export { validateStackExpressions, fieldRuleRootIssue, FIELD_RULE_BOUND_ROOTS } from './validate-expressions.js';
export type { ExprIssue } from './validate-expressions.js';
// [#32872] The gate's verdict on one validation rule or formula field, the
// MCP `validate_expression` tool's second half (the shared validator is its first).
// [#32872] The gate's verdict on one validation rule, formula field or
// (#33147) flow condition, the MCP `validate_expression` tool's second half
// (the shared validator is its first).
export {
judgeAuthoredSiteExpression,
authoredExpressionHopTargets,
AUTHORED_RULE_NAME,
AUTHORED_FORMULA_FIELD_NAME,
AUTHORED_FLOW_NAME,
AUTHORED_CONDITION_ID,
} from './authored-site-expression.js';
export type { AuthoredSiteExpression, AuthoredExpressionSite } from './authored-site-expression.js';
export type { AuthoredSiteExpression, AuthoredExpressionSite, AuthoredFlowTrigger } from './authored-site-expression.js';
export { STORED_EXPRESSION_CROSS_RECORD_INPUT, STORED_EXPRESSION_VOLATILE_INPUT } from './stored-expression-inputs.js';

// #4763 — `has(x)` reads as a null guard and is not one. The decision procedure
Expand Down
Loading
Loading