Skip to content

fix(mcp): validate_expression gives the runtime publish gate's verdict at a flow condition and judges the field being authored - #33168

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-ye3s0h
Nov 14, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-ye3s0h

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread

Fixes #33147

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). It also accepts two new optional inputs, startCondition and field, which is the widening arm. The gate's acceptance set does not change.
Metadata-protocol: none
Skills-docs: none — the published skills say only that the tool runs the build's validator, which stays true; what it judges at each site is taught by the served SKILL.md line this diff updates (and its Console mirror, byte for byte).

维护者速读

  • 改了什么:MCP validate_expression 在 flow_condition 位点按运行时发布门禁对流程写入的判法,跑门禁自己的检查(记录触发流程上 record./previous. 未判空的可空字段报错,外部对象未落存储的系统列告警);新增两个可选入参:startCondition(下游条件继承起始条件已证的非空)和 field(公式位点按该字段本身判:存储字段给存储表达式告警,公式字段给循环判定)。
  • 为什么改:工具承诺给出发布门禁的判定,但在流程条件上只跑共享校验器,门禁拒的条件它答 ok: true,代理到保存流程时才第一次被拒;存储字段的告警和公式循环它也答不出。
  • 风险与回滚:只收窄一个建议性工具的判定,门禁接受集不变,门禁接受的表达式答案与之前逐字相同;两个新入参可选,旧调用不受影响。回滚即 revert 本 PR。
  • 先看:packages/lint/src/authored-site-expression.ts(流程探针与 field 就地替换)、packages/mcp/src/mcp-http-tools.ts 的 validate_expression 入参与 gatePassFindings。
  • 两分钟验证:pnpm --filter @objectstack/lint exec vitest run src/authored-site-expression.test.ts,预期 50 个测试全过。
  • 需要你做的:无,席位落地。

What changes

PR #33134 closed the gap at the validation and formula sites. This PR closes the three gaps it left.

1. flow_condition: the gate's own passes. Measured at the #33134 head (fbdba070) before this change, on a record-triggered flow on task with days nullable. Each condition was placed on the start node, a decision branch and an edge, and all three slots answered alike:

condition os validate (build door) runtime flow publish door tool before tool after
record.days > 3 error error ok error, gate's sentence
previous.days > 3 error error ok error, gate's sentence
record.owner_id == 'u1' on an external object warning warning none warning, gate's sentence
days > 3 (bare) error silent ok ok, as that door
user.id == 'u1', foo.bar == 1 error (unbound root) silent ok ok, as that door
record.title == 'x' (CONTROL) silent silent ok ok, unchanged

The two doors disagree because the runtime publish gate's per-write snapshot shows no entrance, so its root scope is never provable (perWriteSnapshotEntrance). That turns off the bare-name null guard and the unbound-root refusal there. The existing pin in validate-expressions.flow-null-guards.test.ts already records this disagreement.

The seat chose the runtime flow publish door, the one a flow saved through MCP or Studio meets. The tool must never refuse what that door accepts. So ok at a flow_condition is that door's verdict, not os validate's. The tool description, the served SKILL.md line and the changeset each say this in one sentence and tell an agent to write record.FIELD.

  • @objectstack/lint judgeAuthoredSiteExpression takes site: 'flow_condition'. ⛔ It still re-implements no pass. It builds a one-flow probe (authoredFlowProbe): a start node bound to objectName with the trigger the caller names, its start condition when one is given, and one edge to an end node carrying the condition. It then runs the gate's own walk with runtimeWriteType: 'flow' and keeps the findings on that edge's key path (flows[0].edges[0].condition). Each trigger kind is spelled the way resolveFlowTriggerKind reads it, and a pin reads every kind back.
  • The whole entry is now one runStackExpressionPasses call. Only the stack, the write type and the slot filter differ by site. sharedValidatorRunByCaller still has one passer, and the source pin from fix(mcp): validate_expression gives the publish gate's verdict at validation and formula sites #33134 still holds that.
  • @objectstack/mcp: VALIDATE_SITE_MAP.flow_condition gains gatePasses: 'flow_condition'. At a flow condition the tool reads the subject's served definition and nothing else, because nothing hydrates a relationship hop there.

2. New optional input startCondition (site flow_condition only). The gate presumes what a flow's start condition proves at every condition downstream. Without that presumption, the tool would refuse record.days > 3 on an edge after a start condition record.days != null, which the gate accepts. Given startCondition, the probe carries it on the start node, and the gate's own presumption applies. That includes the door split for a bare start guard: days != null proves nothing at the runtime door, so the edge is still refused there and here. Omitted, the condition is judged as the start condition itself. A pin shows that a start probe and an edge probe answer alike when no start condition is given, so no slot input is needed.

3. New optional input field (site formula only, a snake_case field name): the field the expression is authored on. It resolves only through the caller's served definition (servedObjectDefinitionOf).

  • A declared field keeps its definition with the expression in place. A stored field (any type but formula) then gets the gate's storedExpressionInputIssues warnings (card item 2), and a formula field is judged on the object's formula graph under its own name, so formulaCycleIssues refuses the cycle it would close (card item 3).
  • Any other name, including a field masked from the caller, is judged as a new formula field of that name. So the answer never says whether a hidden field exists, and a pin holds a masked field and an absent one to the same answer.

Other surfaces:

  • The tool description and the served SKILL.md line, with its Console mirror byte for byte, name what is judged at a flow_condition, the two inputs, and the limits.
  • startCondition on another site and field on another site are refused, like the existing site-scoped inputs.
  • Changeset .changeset/33147-validate-expression-flow-condition-parity.md: patch for @objectstack/mcp, @objectstack/lint and @object-ui/console, with the BREAKING verdict-narrowing banner and the ADR-0087 not-required (no-migration-prescription) disposition, naming both inputs.

Tests

  • packages/lint/src/authored-site-expression.test.ts (26 → 50 tests)
    • Flow enumeration pin. Every call the gate makes at a flow condition is classified, read off the gate's source through the shared comment mask. The regions are the start node's condition block, the decision branch block, the edge block, and the check / checkDeclaredPredicate helpers they hand the source to. Bare calls and member calls are both covered. The classification:
      • checkFlowNullGuards and warnUnprovisionedAnchors are pass. Each is pinned at all three slots: the entry's findings equal the runtime gate's on a hand-written flow (start, decision, two ends, three edges).
      • refuseUnboundRoots and warnShadowedFieldReads are pass-silent-here, with the reason: os validate's whole-flow judgments over variables, node ids and entrances. Each pin is non-vacuous: os validate speaks on the same condition, given the bindings it reads.
      • refuseStoredMetadataBodyHop and refuseUndeclaredHopField are pass-silent-here: they run only where a hop is hydrated.
      • The bare-name null guard has its own pin: os validate refuses it, and the runtime gate and the entry do not.
      • validateExpression, checkStructuralCondition and predicateSlotRefusal are the validator and envelope checks the tool runs itself. The rest is plumbing.
    • startCondition. A guarded edge passes and a guarded decision passes. A bare start guard still refuses, as at the runtime door. The start condition's own findings are not reported.
    • Trigger kinds. Every kind reads back through resolveFlowTriggerKind, and each answers as the gate does. The null guard fires on a record-triggered flow only.
    • CONTROL, a served definition with injected columns judged as the authored body, the object's own rules ignored, and an absent object answering [].
    • field. A declared stored field gets the gate's stored-expression warnings, and the unnamed probe gets none. A declared formula gets the cycle refusal under its own name. Array-form fields read the same. An undeclared name answers like no name.
    • The fix(mcp): validate_expression gives the publish gate's verdict at validation and formula sites #33134 rows are unchanged. The formulaCycleIssues and storedExpressionInputIssues classifications keep pass-silent-here for the unnamed probe, and their reasons now point at the field block.
  • packages/mcp/src/mcp-validate-expression.test.ts (60 → 72 tests)
    • Tool-level members at flow_condition: the record. null guard, the previous. null guard, and the anchor warning. Each is the gate's verdict and sentence, and the definition-less bridge answered ok before.
    • A bare days > 3 answers exactly as before. A start guard is honoured. A schedule flow gets no null guard. CONTROL equals the definition-less bridge.
    • The subject is the only definition read at a flow condition. The fix(mcp): validate_expression gives the publish gate's verdict at validation and formula sites #33134 pin that said flow_condition reads nothing now covers template alone.
    • A field block with a masking bridge. A stored field's warnings, the cycle refusal, and a field masked from the caller answers exactly as a field the object does not declare (and as no field at all). A bad field name is refused.
    • The refusal table gains the five site-scoped refusals of the two inputs.

Acceptance notes

  • ok at a flow_condition is the runtime publish gate's verdict, not os validate's. os validate also refuses a bare name's unguarded null read, a root the run does not bind, and warns on a read a flow variable shadows. It judges those over the whole flow's variables, node ids and entrances, which the tool is not handed. The runtime door gives none of them, and the tool follows it so that it never refuses a condition that door accepts.
  • The probe's names. A gate sentence that names the slot names it flow 'this flow' · edge 'this condition' (start→end) condition, because the tool is handed the condition and not the flow it will sit in.
  • A new stored field gets no stored-expression warnings until it is declared. An undeclared field is judged as a new formula field, and there is no fieldType input (seat ruling). Once the field is declared, or served in a draft, it is judged as stored.
  • The masked-field guarantee rests on the served definition. field resolves only through servedObjectDefinitionOf, and the runtime bridge serves that through readObjectForCaller, which masks a field the caller cannot read. mcp-object-schema-mask.test.ts's case table already pins that mask from fix(mcp): validate_expression gives the publish gate's verdict at validation and formula sites #33134. This PR adds the tool-level pin that a masked name and an absent name answer alike.
  • A decision branch and an edge are the same slot to the gate, apart from the start-condition presumption, so there is no slot input. A non-start node's config.condition is inert and the gate does not judge it.
  • Ruling grep (ruling|Ruling-ref|maintainer|⛔) before editing, on the surface: authored-site-expression.ts 1 (its own ⛔ no re-implementation), validate-expressions.ts 28, lint/src/index.ts 7, mcp-http-tools.ts 39, skill-md.ts 1. The ones that bear on this change are the root-scope stand-down at the runtime gate (rulings 5791822697 item 3 and 6104584601, cited in flow-cel-root-scope.ts). This PR follows them: the tool judges no root where that door judges none.
  • Writes: relay writes and MCP calls are counted in the os-dev-report comment.

Verification

Head: c21d260c, which is 529ccc6d (the change) with origin/main merged once before opening, cleanly. The merge brought #33117, which also edits mcp-http-tools.ts in another tool, plus two metadata-protocol changes. Every suite and gate below that names c21d260c was re-run after that merge.

Ablations (each through scripts/ablation-replace.mjs, each restored with git diff HEAD empty)
Each through scripts/ablation-replace.mjs, which proves the mutation landed and restores with git diff HEAD empty.
MCP side (pnpm --filter @objectstack/mcp exec vitest run src/mcp-validate-expression.test.ts):
A1  flow_condition loses `gatePasses` in VALIDATE_SITE_MAP
      -> 4 failed | 68 passed (72): the three flow members + "reads the subject alone at a flow condition"
A2  startCondition not handed to the gate
      -> 1 failed | 71 passed (72): "a start condition that guards the read is honoured"
A3  first attempt was a no-op: the replacement text already occurred in the file, so the tool refused it (exit 1, nothing ran). Re-done as A3b:
A3b field not handed to the gate
      -> 2 failed | 70 passed (72): the stored-field warnings and the cycle refusal
A8  hop targets read at a flow condition too
      -> 1 failed | 71 passed (72): "reads the subject alone at a flow condition"
Lint side (pnpm --filter @objectstack/lint exec vitest run src/authored-site-expression.test.ts):
L1  first attempt was a no-op in behaviour: any runtimeWriteType string counts as a runtime-gate write, so the walk was unchanged (50 passed). Re-done as L1b:
L1b flow probe judged at the build door (runtimeWriteType undefined)
      -> 4 failed | 46 passed (50): refuseUnboundRoots silent-here, the bare-name null guard pin, the start-condition pin (bare guard), the trigger-kind pin
L2  a declared field's definition dropped (always added as a new formula)
      -> 2 failed | 48 passed (50): stored-field warnings, array-form fields
L3  flow slot filter widened to the whole probe flow
      -> 1 failed | 49 passed (50): "presumes what the start condition proves" (the start condition's own finding leaked)
L4  first attempt refused: the replacement contained the anchor. Re-done as L4b:
L4b gate: a new call refuseSomethingNew(...) in the edge block of validate-expressions.ts
      -> "every call the gate makes at a flow condition is classified" red (+ "refuseSomethingNew"); the undefined call also throws at run time, so the other flow pins red with it
L5  first attempt refused (an empty replacement). Re-done with --delete as L5b:
L5b the probe drops startCondition
      -> 1 failed | 49 passed (50): "presumes what the start condition proves"
Suites at head
At c21d260c (after the merge), under scripts/pm/os-verify-lock.sh, one && chain, VERDICT command-exit 0:
env -u OS_SKIP_DTS pnpm turbo run build --concurrency=2 --filter='!@objectstack/docs' -> 119 successful, 119 total
pnpm --filter @objectstack/spec check:generated        -> All 14 generated artifacts are up to date
pnpm --filter @objectstack/mcp run typecheck           -> exit 0
pnpm --filter @objectstack/mcp exec vitest run         -> Test Files 64 passed (64) · Tests 1149 passed (1149)
pnpm --filter @objectstack/lint exec vitest run src/authored-site-expression.test.ts -> Tests 50 passed (50)
pnpm --dir ui exec vitest run apps/console/src/pages/developer/objectstack-skill.test.ts -> Tests 13 passed (13) (SKILL.md mirror byte-identical)
pnpm lint                                              -> exit 0

At 529ccc6d (before the merge; packages/lint unchanged by it):
pnpm --filter @objectstack/lint run typecheck          -> exit 0 (source + test programs)
pnpm --filter @objectstack/lint exec vitest run        -> Test Files 208 passed (208) · Tests 8849 passed (8849)
pnpm --filter @objectstack/core exec vitest run src/security/second-object-read-exposure.pin.test.ts -> Tests 4 passed (4) (no new reference-target caller)
Derived gates (dispatch-gates --commands, reconciled with --ran)
At c21d260c: node scripts/pm/dispatch-gates.mjs --commands derives 70 families, the same list as at 529ccc6d (diffed).
All 70 re-run with stdin from /dev/null, exit codes recorded before any pipe, all exit 0.
✓ dispatch-gates --ran: 70 derived famil(ies) accounted for — 70 run, 0 NOT-MEASURED (a DERIVED zero — all 70 recorded an exit code and none of them is 3).

At 529ccc6d the first pass had four non-zero codes on tree state alone, each cleared without a code change:
- check-plugin-teardown-shape --self-test: exit 3 in the shallow clone (pinned fixture 621a4876 unreachable); fetched it; exit 0
- pnpm --dir ui exec vitest run scripts/: exit 1, `Failed to resolve entry for package "@objectstack/client"` (not built); after the full build, exit 0
- check:dual-build-cjs-loads, check:lean-entry-closure: exit 3 (dist not built); after the full build, exit 0
Dogfood battery (owed by the narrowing arm)
At c21d260c (after the full workspace dts build), under the lock:
pnpm --filter @objectstack/dogfood test   (no OS_TEST_SHARD, no paths)
 -> os-verify-lock: VERDICT command-exit 0 · Test Files 462 passed | 3 skipped (465) · Tests 3905 passed | 42 skipped (3947) · 1793s
(The run logs the suites' own expected ERROR lines, for example a showcase schema-sync fixture on sqlite-wasm; no test failed.)

🤖 Generated with Claude Code

https://claude.ai/code/session_01L9qibxiHaRMZVvPqb3cEac

…t at a flow condition and judges a named field

At site flow_condition the tool now runs the gate's own passes through
judgeAuthoredSiteExpression, over a one-flow probe judged as the runtime
publish gate judges a flow write: the null guard on a record-triggered
flow's record./previous. reads, and the unprovisioned-anchor warning.
Two optional inputs: startCondition (the start guard downstream
conditions are presumed under) and field (the formula-site field the
expression is authored on, so a stored field gets the stored-expression
warnings and a formula the cycle verdict).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibxiHaRMZVvPqb3cEac
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 13, 2026
@github-actions

github-actions Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/mcp, touching 24 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/mcp/src/skill-md.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/declarative-endpoints.mdx (via record_change (literal, a string literal in authoredFlowProbe; a string literal in probeTrigger))
  • content/docs/api/plugin-endpoints.mdx (via record_change (literal, a string literal in authoredFlowProbe; a string literal in probeTrigger))
  • content/docs/automation/approvals.mdx (via record_change (literal, a string literal in authoredFlowProbe; a string literal in probeTrigger))
  • content/docs/automation/flows.mdx (via record_change (literal, a string literal in authoredFlowProbe; a string literal in probeTrigger), time_relative (literal, a string literal in probeTrigger))
  • content/docs/automation/hooks.mdx (via record_change (literal, a string literal in authoredFlowProbe; a string literal in probeTrigger))
  • content/docs/automation/workflows.mdx (via record_change (literal, a string literal in authoredFlowProbe; a string literal in probeTrigger))
  • content/docs/concepts/architecture.mdx (via record_change (literal, a string literal in authoredFlowProbe; a string literal in probeTrigger))
  • content/docs/getting-started/common-patterns.mdx (via record_change (literal, a string literal in authoredFlowProbe; a string literal in probeTrigger))
  • content/docs/kernel/services-checklist.mdx (via record_change (literal, a string literal in authoredFlowProbe; a string literal in probeTrigger))
  • content/docs/permissions/capabilities.mdx (via record_change (literal, a string literal in authoredFlowProbe; a string literal in probeTrigger), time_relative (literal, a string literal in probeTrigger))
  • content/docs/permissions/system-context.mdx (via record_change (literal, a string literal in authoredFlowProbe; a string literal in probeTrigger))
  • content/docs/protocol/objectql/schema.mdx (via record_change (literal, a string literal in authoredFlowProbe; a string literal in probeTrigger))
  • content/docs/ui/actions.mdx (via record_change (literal, a string literal in authoredFlowProbe; a string literal in probeTrigger))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx (via record_change (literal, a string literal in authoredFlowProbe; a string literal in probeTrigger))
  • content/docs/releases/v16.mdx (via time_relative (literal, a string literal in probeTrigger))
  • content/docs/releases/v17/17-0.mdx (via record_change (literal, a string literal in authoredFlowProbe; a string literal in probeTrigger))
  • content/docs/releases/v17/17-5.mdx (via time_relative (literal, a string literal in probeTrigger))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/mcp/src/skill-md.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: objectName (literal, 58 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 65 of 247 client-bound route-ledger rows — the other 182 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 182: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 123 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 3e19223bf561694fdbb575dd2490847038916132 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 96a9957ae940f1e635f159074fb0574e94a55bb1 — the merge of head 5eea9771e798ea2b4519a8781cc60df60103af10 into base 3e19223bf561694fdbb575dd2490847038916132, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 96a9957ae940f1e635f159074fb0574e94a55bb1 && git checkout 96a9957ae940f1e635f159074fb0574e94a55bb1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3e19223bf561694fdbb575dd2490847038916132 5eea9771e798ea2b4519a8781cc60df60103af10 && git checkout -B drift-repro 3e19223bf561694fdbb575dd2490847038916132 && git merge --no-ff 5eea9771e798ea2b4519a8781cc60df60103af10

node scripts/docs-audit/affected-docs.mjs --json 3e19223bf561694fdbb575dd2490847038916132

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 3e19223bf561694fdbb575dd2490847038916132 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c21d260c14e29b896a973f293f7c28d4b48dcca7
Local-runs: none
Scope: packages/lint/src/authored-site-expression.ts, packages/lint/src/index.ts, packages/mcp/src/mcp-http-tools.ts, packages/mcp/src/skill-md.ts, ui/apps/console/src/pages/developer/objectstack-skill.ts + none

Face: narrowing (the paths decide no face; the Clause-②: yes (narrowing) line on the PR body and the changeset does). The floor is every non-prose path of the diff, the five files above. No other file in the diff implements them; authored-site-expression.test.ts and mcp-validate-expression.test.ts are read as evidence for the pins. Read outside the diff, never reviewed: packages/lint/src/validate-expressions.ts (the flow walk, check, checkFlowNullGuards, warnUnprovisionedAnchors, warnShadowedFieldReads, judgeFieldFormula, the formulaCycleIssues call), flow-cel-root-scope.ts (perWriteSnapshotEntrance), runtime-gate.ts (runRuntimeAuthoringRules, RuntimeStackContext), authoring-rules.ts (the expression rule's runtimeTypes), stored-expression-inputs.ts, packages/runtime/src/domains/mcp.ts (readObjectForCaller, servedObjectDefinitionOf), packages/mcp/src/stdio-data-bridge.ts, packages/metadata-core/src/object-schema-fls.ts and object-schema-fls-references.ts (the mask).

Inputs: card #33147 body and its 3 comments (triage 6863163129, claim 6864566168, dev report 6867633023; no ruling and no earlier review record is on the thread), PR #33168 body, file list (8 files) and net diff against main, and the check-runs on the head.

Check-runs on the head: 66 concluded, 56 success, 10 skipped, 0 failed, 0 pending (read 2026-11-14T00:02Z). Success includes Build Core, Type Check (workspace, source gates, consumer gates, debt ledger), every Lint family, Test Core 8/8, Test UI 16/16, Type Check UI, Lint UI, Build and E2E UI, Live E2E UI, Dogfood Regression Gate, Check Changeset, PR-body guards and Governed Surface Queue Guard. The 10 skipped are opt-in or queue-only jobs (Queue verdict, Borrowed queue verdict, Build Docs, packed-tarball smoke, 18.x upgrade harness, Temporal Conformance, UI Published Artifacts, Dogfood Verify CLI, the sharded dogfood matrix, the schedule gate).

① Derived judgments

Accept-set and public-surface changes the diff implies, each judged:

  1. flow_condition, record-triggered flow, unguarded nullable record.FIELD / previous.FIELD read: ok: true on main, now ok: false. RIGHT. It is checkFlowNullGuards, run by the gate's own walk on the probe's one edge (afterStart: true); the runtime flow door runs the same call on an edge and a decision branch, and with afterStart: false on the start node, which answers alike when the probe carries no start condition. Nothing is re-implemented: the entry calls runStackExpressionPasses with runtimeWriteType: 'flow', the value runRuntimeAuthoringRules hands the expression rule on a flow write.
  2. flow_condition, any flow bound to an object, a record. / previous. read of an external object's unprovisioned injected column: no warning on main, now the gate's warning. RIGHT. warnUnprovisionedAnchors rides check at every object-bound flow predicate, at both doors.
  3. formula with field naming a served stored field: now the storedExpressionInputIssues warnings. RIGHT. The declared definition is kept and only expression is written over it, so the gate's own test (type !== 'formula' and an expression) decides.
  4. formula with field naming a served formula: now the formulaCycleIssues refusal on that field. RIGHT. The field sits in the object's formula graph under its own name; only the finding located on that field's expression is kept.
  5. Is anything refused that the gate accepts? Not for a call that describes its flow. Two defaults can refuse what the door accepts when the caller leaves the context out, and both are stated in the input descriptions: triggerKind defaults to record_change (as on main), and an omitted startCondition judges the condition as the start condition, so an edge after a guarding start is refused until startCondition is passed. The second is new on this head and is the BREAKING arm the changeset declares.
  6. Does anything the gate refuses still answer ok? Against the runtime flow door, no refusal is left out that the tool could give: the bare-name null guard and the unbound-root refusal stand down there (perWriteSnapshotEntrance, rulings 5791822697 item 3 and 6104584601 as cited in flow-cel-root-scope.ts), and the probe follows by construction. Against os validate (the build door), days compared to 3 and an unbound root still answer ok; this is declared in the tool description, the skill line and the changeset. Note that the card's own example is the bare days compared to 3, which therefore still answers ok (see ③, flag 2).
  7. One warning the runtime flow door gives is not given by the tool and is described wrongly (finding F1 in ④): the shadowed-field-read warning. It changes no ok.
  8. Sites validation, formula without field, and template: unchanged. RIGHT. The validation and unnamed-formula branches build the same stack as on main; template has no gatePasses.
  9. New refusals of the two inputs on a site they do not describe (startCondition off flow_condition, field off formula): RIGHT, the same shape as the existing severity / triggerKind / sweepObject refusals; no call valid on main is affected, since neither key was accepted before (unknown keys are refused by strictToolInput).

② Semver level

  • Changeset: patch for @objectstack/mcp, @objectstack/lint, @object-ui/console, with the BREAKING banner and the ADR-0087 marker not-required (no-migration-prescription). This matches AGENTS.md step 3: Metadata-protocol: none, a non-protocol change ships patch whether widening or breaking, and keeps the banner and the disposition. RIGHT.
  • Clause-②: yes (narrowing) is on the PR body and the changeset body, line-initial, with Metadata-protocol: none beside it and a Skills-docs: none — REASON line on the PR body. One arm, the stricter one; the widening (the two inputs) is stated in the same line's prose. RIGHT. The claim comment predicted no (narrowing); the diff does move a public surface (two MCP inputs, three lint exports), so yes is the accurate value.
  • @objectstack/lint public entry: adds AUTHORED_FLOW_NAME, AUTHORED_CONDITION_ID and the type AuthoredFlowTrigger; AuthoredExpressionSite gains 'flow_condition'; AuthoredSiteExpression gains four optional keys. All additive, and all named in the changeset. authoredFlowProbe is exported from the module only, not from the package entry. No export is removed or renamed.
  • MCP inputs: startCondition and field are both .optional(); a call without them reaches the same code path as on main at validation, formula and template. Additive.

③ Boundary flags

PR author login: objectstack-fleet[bot] (relay; attribution os-tesla, dev session session_01L9qibxiHaRMZVvPqb3cEac, branch claude/project-thread-ye3s0h). The PR is a draft.

  1. Dev report: open_questions: [], security_findings: none, out_of_scope_findings: []. Nothing to answer. The diff stays inside the claim's file surface.
  2. Two decisions are attributed to the seat and have no record on the card thread (should-fix, process): "The seat chose the runtime flow publish door" and "there is no fieldType input (seat ruling)". Both shape the contract: the first is why the card's own example (days compared to 3, bare) still answers ok, the second is why a stored field that is not declared yet gets no stored-expression warning. I judged both on the tree and find them sound (① item 6; the bare-name judgment needs the flow's variables and entrances, and a probe that declares none would refuse a name a real flow rebinds). The seat should put both rulings on the card before ACCEPT, and say there that the card's example is answered by the record.FIELD spelling.
  3. Card item "whether to add a field input is the implementer's to propose": proposed, built, declared as the widening. Answered.
  4. Triage note "the formula-cycle item may need an optional field input; that is a contract widening, so the PR declares it": declared. Answered.

④ Design judgment

packages/lint/src/authored-site-expression.ts — holds.

  • The probe is the smallest flow on which the door's walk reaches the three calls it makes at an edge. The slot filter is the edge's key path flows[0].edges[0].condition, which the walk stamps on every finding of a top-level edge, so the start condition's own findings and every finding on the objects are dropped.
  • objects: [body, ...others] strips the injected system columns as the fix(mcp): validate_expression gives the publish gate's verdict at validation and formula sites #33134 branches do; RuntimeStackContext.objects is declared "as authored", so the door reads the same shape.
  • withAuthoredFormulaField reads the declared field with an own-property test and a record test, in both the map and the array form.
  • F1, should-fix. The module header says the runtime door "never judges ... a read a flow variable shadows". The tree says otherwise: warnShadowedFieldReads runs at the start, decision and edge slots with no stand-down on a runtime write, and it reads only the written flow's own declared variables, which a per-write snapshot does carry. So a flow saved through MCP that declares a variable named like a field of its object, and reads that name bare in a condition, gets this warning at the door and none from the tool. The tool cannot give it (it is handed no variables), so the fix is the wording: say the door gives it and the tool is not handed what it needs. The test's own reason for this row is accurate; the header, the handler's jsdoc and the changeset sentence are not. No ok moves.

packages/lint/src/index.ts — holds. Three additive exports, named in the changeset.

packages/mcp/src/mcp-http-tools.ts — holds, with F1 and F2.

  • Fence and mask: guard(objectName) runs first, as before. The subject is read through servedObjectDefinitionOf, the same per-caller read describe_object projects, and at a flow condition no other object is read (hops is empty), which a pin holds. sweepObject only labels the probe's trigger; its definition is never fetched.
  • field resolves in the served definition and nowhere else. I tried to tell a masked field from an absent one and could not: the mask removes the field and every reference to it (a readable formula that reads it loses its expression), so the formula graph the cycle pass walks carries no edge into the hidden name; a stripped system column and a masked one produce the same stack; the validator half does not read field. Masked, absent and no field answer alike, and a tool-level pin holds that over three expressions.
  • No record is read: the handler calls describeObject, optionFieldsOf and servedObjectDefinitionOf only.
  • F1 applies to the VALIDATE_SITE_MAP jsdoc ("that door does not give them").
  • F2, should-fix. The site input still says "flow_condition binds fields bare" and the tool description still says "(fields are bound bare in flow conditions)", while the new sentence says to write record.FIELD to be judged. Both spellings bind, but an agent that follows the first gets ok on exactly the read the null guard exists for. Say in the site description that both bind and that only the record. spelling is null-judged here.
  • Nit: startCondition is not validated itself; a malformed one proves nothing and says nothing. The PR body states that its findings are not reported.
  • Nit: the field pattern admits __validate_expression__ and __proto__; both are handled as plain own keys and judged as a new formula.

packages/mcp/src/skill-md.ts and ui/apps/console/src/pages/developer/objectstack-skill.ts — hold. The validate_expression entry is byte-identical in the two files at this head (compared as extracted text, 33 lines). It agrees with the tool: the two inputs, the record. / previous. null guard on a record-triggered flow, the anchor warning at three sites, the stored-field warnings, the door named, and "a field not yet declared is judged as a new formula". It names os validate's bare-name and unbound-root judgments only, which is accurate.

Observation, outside this diff: the validator half of the tool has never been handed a flow's variables (flowVariables, bareNamesUnproven), so its type verdict on a bare name can differ from the door's on a flow that rebinds the name. It predates this PR; it belongs on a follow-up card with F1's gap.

Findings: F1 should-fix, F2 should-fix, ③ flag 2 should-fix (process), two nits. No must-fix.

Implemented-by: claude/project-thread-ye3s0h
Reviewed-by: session_01L9qibzuZJBQY4wUQFJbkfx

VERDICT: PASS

…see, and read fields as record.FIELD at every site

The lint entry header, the site-map jsdoc and the changeset said the
runtime flow publish door never gives the shadowed-field-read warning.
It does; the tool just is never handed the flow's variables. The site
input and the tool description still said flow conditions bind fields
bare, against the record.FIELD advice. No answer changes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibxiHaRMZVvPqb3cEac
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5eea9771e798ea2b4519a8781cc60df60103af10
Local-runs: none
Scope: packages/lint/src/authored-site-expression.ts, packages/lint/src/index.ts, packages/mcp/src/mcp-http-tools.ts, packages/mcp/src/skill-md.ts, ui/apps/console/src/pages/developer/objectstack-skill.ts + none

Delta round on record 6868101966 (head c21d260c, PASS with F1, F2 and one process flag). This head is that one plus a single commit, 5eea9771. Judged: git diff c21d260c..5eea9771 (3 files, 17 insertions, 13 deletions), the net diff against main where the delta touches it (the same 8 files as before), card #33147 with its 4 comments (the new one is the seat's rulings, 6868118121), and the check-runs on this head. Everything the first record judged and the delta does not touch stands as judged there.

Check-runs on the head: 66 concluded, 56 success, 10 skipped, 0 failed, 0 pending (read 2026-11-14T01:07Z). The same 10 opt-in or queue-only jobs are skipped as on the previous head.

① Derived judgments

The delta moves no accept set. Its hunks are:

  • authored-site-expression.ts: the module header comment only (lines 34 to 41). No statement changes.
  • mcp-http-tools.ts: the VALIDATE_SITE_MAP jsdoc, one parenthesis in the tool description string, and the site input's .describe() string. No schema shape, no handler statement, no refusal changes.
  • the changeset: one paragraph.

So the commit is prose only: comments, two description strings and the changeset. The two strings are served bytes (an agent reads them in tools/list), but they change no verdict of any call. Every derived judgment of the first record (items 1 to 9) holds unchanged on this head. Item 7 is now declared, see ④.

② Semver level

Unchanged and still right: patch on the three packages, the BREAKING banner, the ADR-0087 marker, Clause-②: yes (narrowing) and Metadata-protocol: none on the PR body and the changeset. The delta adds no export and no input. The changeset paragraph it rewrites stays inside the same declaration.

③ Boundary flags

PR author login: objectstack-fleet[bot] (relay; attribution os-tesla). The PR is still a draft.

  • The process flag of the first record is answered. Comment 6868118121 puts both seat rulings on the card: the runtime flow publish door as the gate the tool matches, with the consequence stated that the card's own bare example still answers ok; and no fieldType input, with its reason. It also records Clause-②: yes (narrowing), which settles the difference from the claim's predicted no (narrowing).
  • No new dev flag and no open question arrived with this head.

④ Design judgment

F1, closed. Each of the three places now says what the tree does:

  • the lint header: the door "does warn on a bare read of a name a flow variable shares with a field (warnShadowedFieldReads)", the entry is handed no variables, "the probe declares none and that warning never fires here", and an empty answer is the door's verdict "short of the shadowed-read warning". Accurate: warnShadowedFieldReads runs at the start, decision and edge slots with no stand-down on a runtime write and reads the written flow's own declared variables; authoredFlowProbe builds a flow with no variables.
  • the VALIDATE_SITE_MAP jsdoc: the bare-name null guard and the unbound-root refusal are the two the door does not give; the shadowed-read warning is named as one the door gives and the tool cannot. Accurate, for the same reason, and consistent with perWriteSnapshotEntrance for the first two.
  • the changeset says the same in one sentence.
    The lint test's reason for this row already said so and is unchanged.

F2, closed. The tool description's parenthesis now reads "read fields as record.FIELD there too", and the site input says every site reads fields as record.FIELD, that a bare field name also resolves in a flow condition, and that this tool cannot judge its null guard there. Accurate against the handler: flow_condition keeps scope: 'flattened', so both spellings resolve in the validator; the null guard the probe gives is on record. and previous. reads only. The description no longer steers an agent to the spelling the tool is blind to.

skill-md.ts and the Console mirror: untouched by the delta, still byte-identical to each other on this head (same extracted text as before), and still in agreement with the tool description after the two string changes.

index.ts: untouched.

One nit, new: "Every site reads fields as record.FIELD" is slightly wide for a flow that no record starts (a schedule flow binds no record; os validate refuses that root there, the runtime door and the tool do not). It has no effect on any verdict and the sentence is right for every flow the null guard applies to.

The two nits of the first record (startCondition is not validated itself; the field pattern admits __proto__ and __validate_expression__, both handled as own keys) are left as they were, which is acceptable.

Findings on this head: no must-fix, no should-fix, three nits.

Implemented-by: claude/project-thread-ye3s0h
Reviewed-by: session_01L9qibzuZJBQY4wUQFJbkfx

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx

维护者速读 · PR #33168 @ 5eea9771 · 2026-11-14T01:10Z

  • 改了什么:MCP validate_expression 在 flow_condition 上给出与运行时 flow 发布门同一个结论:记录触发的流程里未做空值保护的 record.<字段> / previous.<字段> 现在回 ok: false,外部对象未落库的注入列给出同一条警告。formula 站点可以用新的可选 field 指名一个已提供的字段,得到存储表达式的警告和公式环的拒绝。另有可选 startCondition。
  • 为什么改:以前这个工具说 ok,发布时却被拒,代理要白走一轮(mcp: validate_expression answers ok at site flow_condition (and for a stored field's expression at site formula) where the publish gate refuses or warns #33147,p1)。
  • 权限与边界:收窄(工具新拒的都是发布门本来就拒的),外加两个可选输入的放宽。字段只从调用方能看到的对象定义里读;被遮蔽的字段与不存在的字段回答一样,不读任何记录。
  • 风险与回滚:BREAKING 在于不传 startCondition 时按起始条件判,所以起始条件已守住的边会被拒,直到调用方传入它;changeset 写明了。回滚就是 revert。
  • 先看:authored-site-expression.ts 的单流程探针(authoredFlowProbe)和槽位过滤;mcp-http-tools.ts 的 field 解析与遮蔽。
  • 需要你做的:无,席位落地。
  • 席位意见:落地。契约复审两轮(CONTRACT_REVIEW_TIER,Fable):首轮 PASS 附两项应修(措辞),第二轮增量只改文字,PASS。CI 无失败,没有碰受管面,改动 1063 行。

席位核对

  • 形态: draft → main。正文写 Fixes #33147,合并后应当关卡;正文其他地方没有挨着别的 open 卡号的闭合关键词。
    • 申报 Clause-②: yes (narrowing)(同一行写明两个可选输入是放宽臂)、Metadata-protocol: none、Skills-docs: none — 理由;@objectstack/mcp、@objectstack/lint、@object-ui/console 均 patch,带 BREAKING 横幅和 ADR-0087 not-required (no-migration-prescription)。认领预计 no (narrowing),因为公开面确实动了(两个 MCP 输入、三个 lint 导出),所以 yes 是准确值,复审首轮认可。
    • 文件面 = 认领 6864566168,8 个文件都在其内。
  • 前提实测: dev 报告 6867633023:在 fix(mcp): validate_expression gives the publish gate's verdict at validation and formula sites #33134 合并后的 main 上复现;席位设计裁决(对齐运行时 flow 门、不设 fieldType 输入)已在卡上留档 6868118121。
  • 门禁 check-runs @ 5eea9771: 56 success、10 skipped、0 失败、0 进行中。skipped 为选择性或仅队列跑的任务。
    • dev 在 5eea9771 本地:dispatch-gates 推导 70 族全部 exit 0,--ran 0 NOT-MEASURED;pnpm lint 0;mcp 1149/1149,lint authored 50/50,ui 镜像 13/13。
  • 契约复审:
    • 首轮 6868101966 @ c21d260c:PASS,应修 F1(三处把影子字段读警告说成门不给)、F2(site 描述仍说「字段裸绑定」)、③ 流程项(两条席位裁决未在卡上留档,已补 6868118121),nit 两项。
    • 第二轮 6869088705 @ 5eea9771:PASS。c21d260c..5eea9771 一个提交、3 个文件,只改注释、描述字符串和 changeset;F1、F2 关闭,措辞与代码一致。
  • 接受的偏差:
    • nit:startCondition 本身不被校验;畸形的起始条件不证明任何事,也不报告(正文已说明)。
    • nit:field 的模式允许 __proto__ 与 __validate_expression__,两者都按普通自有键安全处理。
    • nit:「每个站点都以 record.<字段> 读字段」对不由记录启动的流程(如定时流程)略宽,不影响任何结论。
    • PR 正文的验证记录仍写 c21d260c;第二轮只改文字,本 ACCEPT 记录了 5eea9771 上的门禁与本地结果。
  • 范围外去向: 工具在 flow_condition 上没拿到流程变量,所以裸名的类型结论和影子读警告可能与门不同;已由席位开卡 mcp: validate_expression at flow_condition judges a bare name without the flow's variables, so its type verdict and the door's shadowed-read warning can differ from the runtime flow door #33176,不在本 PR 处理。
  • 落地条款: 维护者常设落地令 「按 pm-dispatch 审过的pr你可以自主落地」 (2026-10-30T14:20Z)。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mcp: validate_expression answers ok at site flow_condition (and for a stored field's expression at site formula) where the publish gate refuses or warns

2 participants