Skip to content

fix(mcp): validate_expression gives the publish gate's verdict at validation and formula sites - #33134

Merged
objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/project-thread-wqhn19
Nov 13, 2026
Merged

objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/project-thread-wqhn19

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread

Fixes #32872

Clause-②: no (narrowing) — the MCP validate_expression tool now answers ok: false (or a warning) at a validation or formula site for an expression the publish gate already refuses (or warns on). The gate's acceptance set does not change, and no existing exported type or signature changes.
Metadata-protocol: none
Skills-docs: none — the published skills (objectstack-formula, objectstack-ai) say only that the tool runs the build's validator, which stays true; what it refuses is taught by the served SKILL.md line this diff corrects.

维护者速读

  • 改了什么:MCP validate_expression 在 validation / formula 位点除共享校验器外,还跑发布门禁自己的那几道检查(经 lint 新导出的 judgeAuthoredSiteExpression,对调用者被服务的对象定义跑门禁本身的遍历),门禁拒的它也拒;跳转目标与 describe_object 守同一道系统对象围栏;lint 在首次需要时才懒加载;服务端 SKILL.md 与 Console 镜像同步改那一行。flow_condition 位点不在本 PR(席位另开卡)。
  • 为什么改:工具承诺"给出发布门禁对这条规则的判定",却只跑共享校验器,对空值未守卫、跳到目标对象未声明字段、跳进存储元数据表、校验规则读 parent 这几类答 ok: true,到发布时才第一次被拒。
  • 风险与回滚:只收窄一个建议性工具的判定,门禁的接受集不变;门禁接受的表达式答案与之前逐字相同。@objectstack/mcp 把 @objectstack/lint 从 devDependency 挪到 dependency(无环)。回滚即 revert 本 PR。
  • 先看:packages/lint/src/authored-site-expression.ts(入口:构造门禁要判的 stack 再跑门禁自己的 walk)与 packages/lint/src/validate-expressions.ts 新增的 sharedValidatorRunByCaller(仅跳过共享校验器那一次调用)。
  • 两分钟验证:pnpm --filter @objectstack/lint exec vitest run src/authored-site-expression.test.ts 与 pnpm --filter @objectstack/mcp exec vitest run src/mcp-validate-expression.test.ts。
  • 需要你做的:无,席位落地。

What changes

This closes #32872 at the two sites the card names, validation and formula. The gate's own passes at a flow_condition (the unprovisioned-anchor warning on an object-bound flow predicate, and the null-guard gate on a record-triggered flow's start, decision and edge conditions) are not run by the tool, and this PR does not change its verdict there. They are a follow-up card.

The tool promises the verdict the publish gate gives the rule, but it ran the shared expression validator (@objectstack/formula's validateExpression) and nothing else. Measured on origin/main before the change, it answered ok: true at these sites for rules the gate refuses:

site expression (fixture) gate verdict tool before tool after
both record.balance.nope (hop to a field the target does not declare) error ok error, gate's sentence
both a hop into a stored-metadata table error ok error, gate's sentence
validation record.days > 3, days neither required nor defaulted (null guard) error ok error, gate's sentence
validation a read of parent error ok error, gate's sentence
both record.owner_id on an ADR-0015 external object warning none warning, gate's sentence
  • @objectstack/lint
    • New judgeAuthoredSiteExpression (packages/lint/src/authored-site-expression.ts). ⛔ It re-implements no pass. It builds the stack the gate would judge (the subject object carrying the one probe rule or probe formula field, plus the hop targets it reads through) and runs the gate's own walk runStackExpressionPasses over it, as the object save door does (runtimeWriteType: 'object'). It returns exactly the findings the gate locates on that rule or field: by key path objects[0].validations[0]. at a validation site, and by the field's where at a formula site. Any pass later added to the gate at either site is carried by construction.

    • New authoredExpressionHopTargets: the reference targets the expression reads through, read with the analysis and target accessor the gate's hop passes use.

    • The gate gains one option, sharedValidatorRunByCaller, which skips only the validateExpression call the caller has already made. Every other pass runs unchanged. No gate caller passes it. Its one passer is the new entry, and a source pin in authored-site-expression.test.ts now holds that by test (grep at 8a5f028f):

      $ grep -rn "sharedValidatorRunByCaller" packages scripts ui apps   (excluding node_modules, dist)
      packages/lint/src/validate-expressions.ts:2277:  sharedValidatorRunByCaller?: boolean;
      packages/lint/src/validate-expressions.ts:2281: * [#32872] What {@link StackExpressionOptions.sharedValidatorRunByCaller}
      packages/lint/src/validate-expressions.ts:2324:  // [#32872] See {@link StackExpressionOptions.sharedValidatorRunByCaller}.
      packages/lint/src/validate-expressions.ts:2325:  const validatorRunByCaller = options.sharedValidatorRunByCaller === true;
      packages/lint/src/authored-site-expression.ts:22: * already made left out (`sharedValidatorRunByCaller`). What comes back is
      packages/lint/src/authored-site-expression.ts:131:    { runtimeWriteType: 'object', sharedValidatorRunByCaller: true },
      
      $ grep -rn "runStackExpressionPasses(" packages apps   (excluding node_modules, dist, tests)
      packages/lint/src/validate-expressions.ts:2295:  return runStackExpressionPasses(stack, {});
      packages/lint/src/authoring-rules.ts:827:      runStackExpressionPasses(stack, { runtimeWriteType: ctx.runtimeWriteType }).map((i) => ({
      packages/lint/src/authored-site-expression.ts:129:  const issues = runStackExpressionPasses(
      
    • Served definitions carry the system columns the registry injects as declared fields. The gate judges the body as authored, so the entry reads each one through stripInjectedSystemColumns (@objectstack/metadata-core, the write path's own strip). Without it, the null guard would refuse created_at, and an external object's anchors would read as columns its author vouches for.

  • @objectstack/mcp
    • McpDataBridge gains the optional servedObjectDefinitionOf(name): the object definition as the caller is served it (the read describeObject projects). It is handed to the gate and rendered to nobody. A bridge without it answers the shared validator's verdict alone, as before. The stdio bridge serves it from its served view.
    • validate_expression at formula and validation merges the gate's findings into errors / warnings, as { source, message } in the gate's words at the gate's severity. A flow_condition and a template are not judged by this change, and no definition is read for them.
    • Hop targets follow the same system-object fence as describe_object: a target the tool's object-name guard refuses is neither read nor judged, so a hop into a system object is judged only where the server sets allowSystemObjects.
    • The tool description and the served SKILL.md line name what the tool now refuses, give the unprovisioned-column warning its two sites, and state the fence.
    • New runtime dependency: @objectstack/lint moves from devDependencies to dependencies (packages/mcp/package.json, with the matching six-line pnpm-lock.yaml move). No cycle: @objectstack/lint depends only on @objectstack/formula, @objectstack/metadata-core, @objectstack/sdui-parser and @objectstack/spec, and none of those depends on @objectstack/mcp. @objectstack/runtime and @objectstack/cli already depend on @objectstack/lint at runtime. Loaded lazily: gatePassFindings imports lint's root entry with await import('@objectstack/lint') on the first call that judges a rule or formula field, and the module imports only its types, so the boot path still reaches lint only through @objectstack/lint/runtime, as every other server-side consumer does. The full workspace build orders it cleanly (119/119 tasks).
  • @objectstack/runtime: the HTTP MCP bridge serves servedObjectDefinitionOf through readObjectForCaller, the same caller-scoped read describeObject uses (ADR-0106 D1), so a field masked from the caller is masked from the gate's input too.
  • @object-ui/console: the downloadable SKILL.md mirror carries the same line, byte for byte.
  • Changeset .changeset/32872-validate-expression-gate-parity.md: patch for the four packages, with the BREAKING verdict-narrowing banner and an ADR-0087 not-required (no-migration-prescription) disposition. Every expression the tool now refuses, the gate already refused, so neither stored metadata nor consumer code has anything to rewrite.
  • Registration only: scripts/cross-package-test-inputs.mjs (the @objectstack/lint entry) and turbo.json (@objectstack/lint#test inputs) declare scripts/js-comment-mask.mjs and its .d.mts, which the new lint test imports to read the gate's source without its comments.

Tests

  • packages/lint/src/authored-site-expression.test.ts (new, 26 tests)
    • Enumeration pins. The test reads the gate's source through the shared comment mask.
      • Per site, inside the gate's regions for it (check plus the validation-rule loop for validation; judgeFieldFormula plus the span after its call site for formula), every call by bare name is classified as validator, plumbing, pass (with a refusal fixture) or pass-silent-here, and every member call by its method name is classified as plumbing.
      • Across the whole walk (runStackExpressionPasses), every pass handed to the walk as issues.push(fn( or issues.push(...fn(, member names included, is classified. That is the shape of the walk's whole-stack passes at its tail (bulkConfirmTextIssues, actionCopyTemplateIssues).
      • formulaCycleIssues (from main) is classified pass-silent-here at the formula site, with a pin that it fires on the object's own cycle and locates nothing on the probe.
      • Each addition reds until it is classified. Proven by ablation: a new bare call inside judgeFieldFormula, a formulaCycleIssues-shaped spread after the fields loop, a new member call there, and a new issues.push(...) beside bulkConfirmTextIssues.
      • What it cannot see (also in the test's docblock): a pass outside the regions that pushes by another shape (an inline issues.push({ … }), a loop over a helper's result, a helper that pushes into issues itself), and a pass handed by reference (.map(fn)). Such a pass is still carried by construction when it locates its finding on the judged rule's key path or the judged field's where. What no pin catches is a pass located elsewhere, or one that needs an input the tool is not handed.
    • The skip option reaches no gate run. A source scan over lint's own sources (tests aside, comments and literals masked) holds sharedValidatorRunByCaller to its declaration, the walk's one read, and the one passer in authored-site-expression.ts. A second scan holds runStackExpressionPasses and StackExpressionOptions off all three package entries.
    • One refusal pin per pass. For each pass, the entry's findings equal the gate's own findings on the same stack (the full runStackExpressionPasses minus the shared validator's).
    • CONTROLs. An accepted rule and an accepted formula answer [].
    • Also: the validator is left to the caller; the subject's own rules are ignored; a served definition (with injected columns) judges as authored; an absent object answers []; hop-target reads.
  • packages/mcp/src/mcp-validate-expression.test.ts: a new describe block with one tool-level pin per member (8). It also pins that an injected created_at is not refused, that only hop targets are read, a CONTROL equal to the definition-less bridge, that a bridge without the member answers as before, and that flow_condition / template read no definition. Fence pin: a hop into a served sys_user is refused with allowSystemObjects on. By default the target is not read, and the answer equals the definition-less bridge's.
  • The two bridge-member ledgers (mcp-write-response-internal-fields.tripwire.test.ts, stdio-data-bridge.stored-metadata-body.test.ts) classify the new member. Both went red without the entry, as their ledgers intend.
  • packages/runtime/src/domains/mcp-object-schema-mask.test.ts adds servedObjectDefinitionOf to the ADR-0106 case table, with a masked field-set assertion. mcp-option-fields-read.test.ts pins that it equals the served object, that its keys equal describeObject's fields, and that it answers null when the object is absent.

Acceptance notes

  • Findings carry no code. The gate's ExprIssue has none, so the tool's added findings are { source, message } with the gate's sentence.
  • The probe rule is named this rule. The tool is handed the condition, not the rule it will sit on, so a gate sentence that names the rule (the null guard, parent) names it this rule. The probe formula field is spelled __validate_expression__, and no gate sentence at the formula site names it.
  • The formula-cycle pass (fix(objectql)!: a formula reading another formula reads its computed value; a cycle of formulas is refused where it is authored #33108) is classified pass-silent-here at the formula site. It landed on main and this branch merged it. Its call sits after the fields loop, inside the formula region, and both the formula enumeration pin and the whole-walk push pin went red on the merge (+ "formulaCycleIssues") until it was classified. It locates each finding on a cycle member's own expression. The probe is named __validate_expression__, which no authored formula can read, so the probe is never on a cycle. A pin shows the pass does fire on the object's own cycle, and that neither the gate nor the entry locates a finding on the probe. Limitation: the tool cannot give the cycle verdict for the formula being authored, because it is not handed that field's own name. That belongs with the follow-up card.
  • storedExpressionInputIssues is pass-silent-here. That holds for a formula probe. A stored (non-formula) field's expression authored through site: 'formula' gets the gate's stored-input warnings and none from the tool. This goes with the follow-up card.
  • Context the one-object probe stack does not share with the gate. These are corners, written down. Only the first two are cases where the tool refuses and the gate does not.
    • Package closure: the object save door narrows objects to the written package's closure, so a target outside it is not judged there. The tool judges any target the caller is served. The tool's refusal is true (the field does not exist), and the door's silence is the gap.
    • A target field masked from the caller is refused as undeclared. This conforms to ADR-0106 D1 and to what the shared validator already answers for a masked subject field.
    • A target the caller is not served, or a system-object target behind the fence, is not judged, so the tool stays ok where the gate refuses. This residual runs in the pre-existing direction.
    • A picklist-bound field is served with resolved options. A default option makes it non-nullable for the tool, while the authored body the gate reads names only the picklist.
    • The entry finds the subject by name === objectName. On the bridge's no-protocol fallback, a differently spelled name answers [].
    • A fault-tier target turns the call into a door error instead of a verdict, the same answer describe_object gives for that target.
  • Scope of the hint lists. The tool's fields / stdlib hints still come from the tool's own index. Only the verdict reads the gate.
  • The shared validator runs once. The tool keeps its own validateExpression call, which carries codes and the site options. The gate's copy is skipped, so a validator finding is never reported twice.

Verification

Head: 8a5f028f. Round 2 is the review fixes in e306a89d, then origin/main merged for #33108 with its pass classified (bd2b25ff), then merged again for #33142, which touched mcp-http-tools.ts (8a5f028f, clean). Round 1 was 95b340e2.

Measurement on origin/main (before)

A throwaway vitest file (not committed) ran the gate's walk and the tool's handler on the same fixtures at origin/main:

1 null-guard | validation | `record.days > 3` | gate: error: | tool ok=true, errors=0, warnings=0
1 null-guard (formula) | formula | `record.days > 3 ? 1 : 0` | gate: none | tool ok=true, errors=0, warnings=0
2 undeclared hop | validation | `record.balance.nope > 1` | gate: error: | tool ok=true, errors=0, warnings=0
2 undeclared hop | formula | `record.balance.nope > 1 ? 1 : 0` | gate: error: | tool ok=true, errors=0, warnings=0
3 stored body hop | validation | `record.meta.name == 'x'` | gate: error: | tool ok=true, errors=0, warnings=0
3 stored body hop | formula | `record.meta.name == 'x' ? 1 : 0` | gate: error: | tool ok=true, errors=0, warnings=0
3 parent root | validation | `parent.remaining > 1` | gate: error: | tool ok=true, errors=0, warnings=0
3 parent root (formula) | formula | `parent.remaining > 1 ? 1 : 0` | gate: none | tool ok=true, errors=0, warnings=0
3 unprovisioned anchor | validation | `record.owner_id == 'u1'` | gate: warning: | tool ok=true, errors=0, warnings=0
3 unprovisioned anchor | formula | `record.owner_id == 'u1' ? 1 : 0` | gate: warning: | tool ok=true, errors=0, warnings=0
control | validation | `record.title == 'x'` | gate: none | tool ok=true, errors=0, warnings=0

The formula rows for the null guard and parent show no gate finding: the gate runs those two passes only at a validation rule, and the entry follows it.

Ablations (each red, each restored, `git diff HEAD` empty after)
Ablations through scripts/ablation-replace.mjs (restores the file; git diff HEAD empty after each):
A1  mcp: handler skips gatePassFindings              -> mcp-validate-expression.test.ts  9 failed | 49 passed (58); CONTROL stays green
A2  lint: entry runs the shared validator too        -> authored-site-expression.test.ts 1 failed | 18 passed (19): "leaves the shared validator's findings to the caller"
A3  lint: no stripInjectedSystemColumns              -> 1 failed | 18 passed (19): "judges a served definition (injected columns declared) as the authored body"
A4b lint gate: new refuseSomethingNew(...) call inside judgeFieldFormula
                                                     -> 1 failed: "site formula: every call the gate makes there is classified" (+ "refuseSomethingNew")
A5  lint: formula findings filter dropped            -> 3 failed | 16 passed (19): the three formula refusal pins
A6b lint gate: issues.push(...formulaCycleIssues(...)) after the fields loop (the #33108 shape)
                                                     -> 1 failed: "site formula: every call the gate makes there is classified"
(A4b and A6b were re-run after the scan moved onto maskCommentsAndLiterals.)

Round 2 (review delta), through scripts/ablation-replace.mjs, each restored with git diff HEAD empty:
F1   mcp: hop-target fence dropped (`|| !exposed(target)` removed)
       -> mcp-validate-expression.test.ts 1 failed | 58 passed (59): "a hop target follows the same system-object fence as describe_object"
F3a  lint gate: a new member call inside judgeFieldFormula (`Object.freeze(Object.keys(f))`)
       -> authored-site-expression.test.ts 1 failed | 23 passed (24): "site formula: every member call the gate makes there is classified" (+ "freeze", "keys")
F3b  lint gate: `issues.push(...Array.of())` beside bulkConfirmTextIssues, outside both regions
       -> 1 failed | 23 passed (24): "every pass the whole walk pushes as a whole is classified" (+ "Array.of")
F3c  lint gate: `issues.push(...lintPasses.formulaCycleIssues(stack))` at the same place (a member-named, #33108-shaped pass)
       -> the walk pin reds (the undefined receiver also throws at run time, so other pins red with it: 15 failed)
F8a  lint: authoring-rules.ts:827 also passes `sharedValidatorRunByCaller: true`
       -> 1 failed | 23 passed (24): "is spelled in code only where it is declared, read, and passed by this entry"
F8b  lint: index.ts re-exports runStackExpressionPasses
       -> 1 failed | 23 passed (24): "is on none of the package entries: the walk and its options type are not exported"
C0   merge of origin/main carrying #33108, before classifying its pass (a real addition, not an ablation)
       -> 2 failed | 22 passed (24): "site formula: every call the gate makes there is classified" and
          "every pass the whole walk pushes as a whole is classified", each (+ "formulaCycleIssues")
Suites at head
At 8a5f028f (after the #33142 merge), under scripts/pm/os-verify-lock.sh:
pnpm --filter @objectstack/mcp run typecheck          -> exit 0
pnpm --filter @objectstack/mcp exec vitest run        -> Test Files 63 passed (63) · Tests 1119 passed (1119) · exit 0
pnpm --dir ui exec vitest run apps/console/src/pages/developer/objectstack-skill.test.ts
                                                      -> Test Files 1 passed (1) · Tests 13 passed (13) · exit 0 (SKILL.md mirror byte-identical)

At bd2b25ff (review fixes + the #33108 merge), each under the lock:
env -u OS_SKIP_DTS pnpm turbo run build (workspace, docs aside) -> 119 successful, 119 total
pnpm --filter @objectstack/lint run typecheck         -> exit 0 (source + test programs)
pnpm --filter @objectstack/mcp run typecheck          -> exit 0
pnpm --filter @objectstack/runtime run typecheck      -> exit 0 (re-run with a 6 GB heap: the first run's test program OOMed in tsc, exit null, no diagnostics)
pnpm --filter @objectstack/lint exec vitest run       -> Test Files 206 passed (206) · Tests 8767 passed (8767) · exit 0
pnpm --filter @objectstack/mcp exec vitest run        -> Test Files 63 passed (63) · Tests 1119 passed (1119) · exit 0
pnpm --filter @objectstack/runtime exec vitest run src/domains/mcp-object-schema-mask.test.ts src/domains/mcp-option-fields-read.test.ts
                                                      -> Test Files 2 passed (2) · Tests 71 passed (71) · exit 0
pnpm --dir ui exec vitest run apps/console/src/pages/developer/objectstack-skill.test.ts -> 13 passed (13)
pnpm lint -> exit 0

At 95b340e2 (round 1; packages/runtime unchanged since):
pnpm --filter @objectstack/runtime exec vitest run src/domains/mcp-object-schema-mask.test.ts src/domains/mcp-option-fields-read.test.ts
                                                      -> Test Files 2 passed (2) · Tests 71 passed (71) · exit 0
type-check runtime -> exit 0
Derived gates (dispatch-gates --commands, reconciled with --ran)
Round 2, at bd2b25ff (after the full dts build): the derivation is the same 101 families. All 101 re-run with stdin from /dev/null, exit codes recorded, all exit 0.
(At e306a89d, check:dts-closure and check:dual-build-cjs-loads had gone red on tree state alone, with declaration files missing after an OS_SKIP_DTS=1 closure build; the dts build cleared both.)
At 8a5f028f the derivation is unchanged (101). Re-run after that merge, all exit 0: check-changeset-no-major, check-adr-0087-registration, check-empty-changeset (each --base origin/main), check-closing-keyword-parity, check:cross-package-test-inputs, check-comment-mask-adoption, check:route-envelope, check:undeclared-dep-imports.
✓ dispatch-gates --ran: 101 derived famil(ies) accounted for — 101 run, 0 NOT-MEASURED (a DERIVED zero — all 101 recorded an exit code and none of them is 3).

Round 1, at 95b340e2:
node scripts/pm/dispatch-gates.mjs --commands derives 101 families at head (86 before the last commit touched scripts/, 15 added by it).
Every command ran with stdin from /dev/null and its exit code recorded; all 101 exit 0.
Three were re-run after a fix:
- check-comment-mask-adoption: red at the first head (a private comment stripper in the new lint test); the test now imports maskCommentsAndLiterals; exit 0
- check-plugin-teardown-shape --self-test: exit 3 in the shallow clone (pinned fixture 621a4876 unreachable); fetched it; exit 0
- check:cross-package-test-inputs: red (the lint test now imports scripts/js-comment-mask.mjs); the pair is declared on the @objectstack/lint entry and in turbo.json; exit 0
After the last commit: the 15 new families, plus check:cross-package-test-inputs, check:type-check-coverage, check:type-check-debt, check-comment-mask-adoption, check:dispatcher-error-vocabulary and check:duration-unit-keys (turbo.json moved), all exit 0.

✓ dispatch-gates --ran: 101 derived famil(ies) accounted for — 101 run, 0 NOT-MEASURED (a DERIVED zero — all 101 recorded an exit code and none of them is 3).
Dogfood battery (owed by the narrowing arm)
Round 2, at bd2b25ff (after the full workspace dts build):
pnpm --filter @objectstack/dogfood test   (no OS_TEST_SHARD, no paths, under the lock)
 -> os-verify-lock: VERDICT command-exit 0 · Test Files 460 passed | 3 skipped (463) · Tests 3884 passed | 42 skipped (3926) · 1809s
Not re-run at 8a5f028f: that merge brings only #33142 (main's own upload-link change), and the mcp suite is re-run above.

Round 1, at 95b340e2:
 -> Test Files 460 passed | 3 skipped (463) · Tests 3874 passed | 42 skipped (3916) · exit 0 · 1856s

🤖 Generated with Claude Code

https://claude.ai/code/session_01L9qibzhu4Yeph4D3KQ9Ao3

@github-actions github-actions Bot added size/l dependencies Pull requests that update a dependency file 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 3 package(s): @objectstack/lint, @objectstack/mcp, @objectstack/runtime, touching 21 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/mcp/package.json, 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.

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

  • content/docs/permissions/authorization.mdx (via buildMcpBridge (symbol, a top-level function))
What this run could not see
  • 3 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/mcp/package.json, 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 (symbol, 57 pages)
  • 5 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 — 38 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 1bed8536bba1bd59e432e7b9d8ebc3c526efc637 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b9f7a7b462cf4974e169e887662daf226dffeb04 — the merge of head fbdba0708e50558dd62890d05508f4132ae9b0c3 into base 1bed8536bba1bd59e432e7b9d8ebc3c526efc637, 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 b9f7a7b462cf4974e169e887662daf226dffeb04 && git checkout b9f7a7b462cf4974e169e887662daf226dffeb04
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1bed8536bba1bd59e432e7b9d8ebc3c526efc637 fbdba0708e50558dd62890d05508f4132ae9b0c3 && git checkout -B drift-repro 1bed8536bba1bd59e432e7b9d8ebc3c526efc637 && git merge --no-ff fbdba0708e50558dd62890d05508f4132ae9b0c3

node scripts/docs-audit/affected-docs.mjs --json 1bed8536bba1bd59e432e7b9d8ebc3c526efc637

⚠️ 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 1bed8536bba1bd59e432e7b9d8ebc3c526efc637 → 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: 95b340e227301d6981ca67295c46c58724e43ae6
Local-runs: none
Scope: hits — MCP validate_expression (/api/v1/mcp tools/call and stdio) at site: 'validation' / 'formula'; @objectstack/lint root exports; McpDataBridge.servedObjectDefinitionOf; @objectstack/mcp runtime dependency set; the served SKILL.md line. Implementing files — packages/lint/src/authored-site-expression.ts, packages/lint/src/validate-expressions.ts, packages/lint/src/index.ts, packages/mcp/src/mcp-http-tools.ts, packages/mcp/src/stdio-data-bridge.ts, packages/runtime/src/domains/mcp.ts, packages/mcp/src/skill-md.ts, ui/apps/console/src/pages/developer/objectstack-skill.ts, packages/mcp/package.json, pnpm-lock.yaml, scripts/cross-package-test-inputs.mjs, turbo.json, and the tests (authored-site-expression.test.ts, mcp-validate-expression.test.ts, the two mcp bridge-member ledgers, the two packages/runtime bridge tests).

Read: card #32872 body and all 5 comments (claim 6855829217, amendments 6856483394 and 6857864931, os-dev-report 6858983583), PR #33134 body, the net diff 9433d4f3..95b340e2 (19 files, +799/−31) and the files at the head. Check-runs on the head at 16:43Z: 63 total, 23 success, 9 skipped, 31 in progress, 0 failed. Still running: Build Core, Test Core 1–8/8, Test UI 1–16/16, Type Check · workspace, Type Check · consumer gates, Type Check UI, Lint UI, Build & E2E UI, Lint · PM dispatch-gates. Completed green include Check Changeset, Validate Package Dependencies, Governed Surface Queue Guard, PR-body guards, Lint · repo & tooling gates, Lint · docs & code guards, Type Check · source gates, Dogfood Regression Gate. No test or type-check verdict exists yet for this head; this record does not stand in for one.

① Derived judgments

  1. Tool refusals at validation — right. Unguarded nullable operand, parent read, hop to a field the target does not declare, hop into a stored-metadata table (errors), and the unprovisioned-anchor warning. Each is a finding the gate's own rule loop and check produce on the same rule (validate-expressions.ts rule loop, lines 3543–3618 at the head). Narrowing only: ok becomes result.ok && gate.errors.length === 0; nothing the tool refused before is now accepted.
  2. Tool refusals at formula — right. The two hop refusals and the anchor warning, from judgeFieldFormula. No null guard and no parent verdict there, as at the gate.
  3. flow_condition / template unchanged — right as a derived judgment, wrong as stated (see ④ F2): the tool's verdict there does not move, but the gate does not judge a flow condition with the shared validator alone.
  4. Hop-target reads are a new disclosure — wrong, undeclared (④ F1, must-fix). The tool now answers, per probed column, whether a sys_* object declares a field, with allowSystemObjects off. That is a widening of what the tool reveals on a PR declared as narrowing only.
  5. New lint exports judgeAuthoredSiteExpression, authoredExpressionHopTargets, AUTHORED_RULE_NAME, AUTHORED_FORMULA_FIELD_NAME, types AuthoredSiteExpression, AuthoredExpressionSite (packages/lint/src/index.ts) — right, additive, on the root entry only. runStackExpressionPasses and StackExpressionOptions stay off all three package entries.
  6. New gate option sharedValidatorRunByCaller — right. It cannot reach a gate run (④).
  7. New optional bridge member servedObjectDefinitionOf — right, additive on an exported interface; an implementer without it keeps the old verdict. Both in-repo bridges serve the read describeObject projects.
  8. @objectstack/lint becomes a runtime dependency of @objectstack/mcp — right as a dependency (no cycle; a published-package change the changeset states). The way it is loaded is ④ F4.
  9. Response shape — the added findings are { source, message } with no code; validator findings keep theirs. Additive, stated in the PR's Acceptance notes. Right.

② Semver level

  • Changeset .changeset/32872-validate-expression-gate-parity.md: patch on @objectstack/mcp, @objectstack/lint, @objectstack/runtime, @object-ui/console. The four packages are exactly the ones whose published source moves. Metadata-protocol: none is right: no key, value or type an author may write changes, and the gate's acceptance set is untouched. Under AGENTS.md "the level follows the kind alone", none takes patch, and a non-protocol breaking change ships patch with its BREAKING banner and ADR-0087 disposition. Both are present (not-required (no-migration-prescription)); the disposition's reason holds for every refusal judged right in ①.
  • Clause-②: no (narrowing): the arm is right for ① 1–2. It is incomplete for ① 4: the same handler also reveals more than it did (F1). Fixing F1 by guarding the targets restores the declaration as written.
  • The Clause-② sentence ends "no exported type or signature changes". False as written: McpDataBridge gains a member, lint gains six names, StackExpressionOptions gains one. All additive, and the changeset's own bullets list them. Reword to "no existing exported type or signature changes" in both the PR body and the changeset (nit, F5).
  • Skills-docs: none: holds. skills/objectstack-ai/SKILL.md:201 and skills/objectstack-formula/SKILL.md:484 say only that the tool runs the build's validator.
  • Check Changeset is green on the head.

③ Boundary flags

  • os-dev-report open_questions: [], security_findings: none. Not accepted: F1 is a security-relevant behaviour the report does not name. Escalated as must-fix.
  • Dev landing note, fix(objectql)!: a formula reading another formula reads its computed value; a cycle of formulas is refused where it is authored #33108 (formula-cycle pass). Answered with a condition, escalated to the seat's landing step. At 16:43Z origin/main (5c005e0e) carries no change to validate-expressions.ts, mcp-http-tools.ts, skill-md.ts or lint's index.ts since the merge base, so fix(objectql)!: a formula reading another formula reads its computed value; a cycle of formulas is refused where it is authored #33108 has not landed. If it lands first, the formula pin reds only if its call is a bare call inside judgeFieldFormula or in the few lines between the judgeFieldFormula(...) call and the // [#26646] marker. A call placed anywhere else (after the objects loop, beside bulkConfirmTextIssues, or as a member call) leaves the pin green and the tool silently short of the gate. The seat must read where fix(objectql)!: a formula reading another formula reads its computed value; a cycle of formulas is refused where it is authored #33108's call sits when merging main, not rely on the pin. Classification when it lands: pass-silent-here is the honest one (no authored field can reference __validate_expression__, so the probe is never in a cycle), with the limitation written down: the tool cannot give the cycle verdict because it is not handed the field's name.
  • Findings carry no code — accepted (① 9).
  • Probe rule named this rule, probe field __validate_expression__ — accepted. The null-guard and parent sentences read "validation rule 'this rule'", which is intelligible and stated in the changeset.
  • Hint lists still come from the tool's own index — accepted, out of this card.
  • The shared validator runs once — accepted, with the consequence named in ④: the validator half of the verdict agrees with the gate by a parallel hint, not by construction.
  • Card member 3 measured rather than read from source — done by the dev (PR "Measurement on origin/main"); accepted on the PR's evidence, not re-run here.
  • Claim amendments (package.json, ledgers, turbo.json, cross-package inputs): the diff stays inside the amended file surface. No breach.

④ Design judgment

F1 — must-fix. Hop targets bypass the tool's system-object fence. packages/mcp/src/mcp-http-tools.ts:gatePassFindings (line 1804) calls bridge.servedObjectDefinitionOf(target) for every reference target the expression reads through, with no guard(target). guard is "the fail-closed object-name guard shared by every object-scoped tool"; with allowSystemObjects off (the default) describe_object('sys_user') and validate_expression({ objectName: 'sys_user' }) are refused ("is a system object and is not exposed via MCP"), and list_objects filters sys_* at the tool layer, which shows the bridge does serve them. After this diff, validate_expression({ objectName: <any object with a reference to sys_user>, expression: 'record.owner.X > 1', site: 'validation' }) answers unknown field X on sys_user or stays clean, one bit per probed column, to a data:read-scoped caller. The read is still the caller's ADR-0106 projection, so no FLS-masked field is confirmed; what is breached is the MCP tool-layer fence, and bridge-level describeObject parity is not tool-level describe_object parity. No test covers a sys_* hop target (mcp-validate-expression.test.ts:290 pins the subject only). Fix, either: (a) skip a target guard refuses, as an unserved target is skipped, add the pin, and say in the SKILL line and changeset that hops into system objects are judged only with allowSystemObjects; or (b) keep the read and obtain a ruling that this oracle is acceptable, then declare it and pin it. (a) is the fail-closed default and keeps Clause-②: no (narrowing) true. Read from source, not run.

F2 — should-fix. "A flow condition is judged by the shared validator alone, as at the gate" is false. At a flow condition the gate's check also runs warnUnprovisionedAnchors (validate-expressions.ts:2670, "provenance rides every object-bound predicate"), and on a record-triggered flow's start, decision and edge conditions it runs checkFlowNullGuards → checkNullGuards at severity error (lines 3017, 3152, 3257, 3387). So validate_expression({ site: 'flow_condition', triggerKind: 'record_change', expression: 'days > 3' }) still answers ok: true where the publish gate refuses. That is outside this card's two sites, so it does not fail the narrowing, but the sentence ships in the changeset (published CHANGELOG), the PR body, the VALIDATE_SITE_MAP docblock (mcp-http-tools.ts:1719) and a test title (mcp-validate-expression.test.ts:554), and the SKILL line lists the unprovisioned-column warning without a site. Correct the four sentences to "not judged by this change", qualify the SKILL warning to the two sites, and file the follow-up card for the flow members; card #32872 calls itself the closing card for the family, and it is not.

F3 — should-fix. The enumeration pin fails for a narrower set of additions than the card asks. authored-site-expression.test.ts:SITE_REGIONS scans four source regions for bare calls. It reds for a bare call added inside check, the rule loop, judgeFieldFormula, or the short span after its call site. It stays green for a member call, and for a pass added anywhere else in the walk that judges a rule or a formula field (whole-stack passes of the bulkConfirmTextIssues shape already exist at the walk's tail). A pass in the walk located on objects[0].validations[0]. or on the probe field's where is carried by construction regardless, so the exposed case is a pass located elsewhere or needing input the tool lacks. State the pin's reach in the test header, and widen it (for example, classify every top-level issues.push(...fn( of runStackExpressionPasses). See the #33108 flag in ③.

F4 — should-fix. Static import of lint's root barrel at MCP module load. mcp-http-tools.ts:92 imports @objectstack/lint statically. Every other server-side consumer reaches lint through @objectstack/lint/runtime and loads the root lazily (metadata-protocol/src/metadata-diagnostics.ts: "the kernel boot path reaches @objectstack/lint only through its /runtime entry"; build-probes.ts). Lint builds with splitting: false, so a host that already loaded dist/runtime.js now also parses a second self-contained dist/index.js (552,936 B by lint's own runtime.ts header) at boot. The PR's "no host gains a package it did not already load" is true per package and misses the entry. typescript, sucrase and ajv stay lazy (lazy-deps.test.ts), so this is cost, not breakage. Fix: await import('@objectstack/lint') inside gatePassFindings, which is already async.

F5 — nit. Clause-② sentence wording (②).

F6 — nit. storedExpressionInputIssues is classified pass-silent-here. Honest for a formula probe, but a stored (non-formula) field's expression authored through site: 'formula' gets the gate's stored-input warnings and none from the tool. Name it in the follow-up card of F2.

F7 — nit. Context the one-object probe stack does not share with the gate. Each is a corner to write down; the first two are the only ones where the tool refuses and the gate does not:

  • Package closure: the object save door narrows objects to the written package's closure (runtime-gate.ts:narrowObjectsToWriteClosure), so a target outside it is not judged there; the tool judges any target the caller is served. The tool's refusal is true (the field does not exist), the door's silence is the gap.
  • A target field masked from the caller is refused as undeclared. Conformant with ADR-0106 D1 and with what the shared validator already answers for a masked subject field.
  • A target the caller is not served is not judged, so the tool stays ok where the gate refuses. Residual, in the pre-existing direction.
  • A picklist-bound field is served with resolved options; a default option makes it non-nullable for the tool while the authored body the gate reads names only the picklist. Tool accepts, gate may refuse an ordering comparison on it.
  • judgeAuthoredSiteExpression finds the subject by o.name === objectName; on the bridge's no-protocol fallback (getObject(name)) a differently-spelled name would answer [] silently.
  • A fault-tier target now turns the call into a door error instead of a verdict. Same answer describe_object gives for that target.

F8 — nit. "No gate caller passes sharedValidatorRunByCaller" rests on a grep in the PR body. A source-scan pin that the one passer is authored-site-expression.ts would hold it by test.

Questions asked, answered:

  • Agrees with the gate verdict for verdict, by construction? For the gate's own passes at both sites, yes: judgeAuthoredSiteExpression runs runStackExpressionPasses itself over a stack of the subject plus hop targets with runtimeWriteType: 'object', and filters by the rule's key path or the probe field's where. No pass is copied. For the validator half, no: the tool keeps its own validateExpression call with a hint it builds from describeObject, and the gate's call is skipped. That half is parity by parallel construction, pre-existing (fix(formula)!: the gate refuses a date field handed straight to a call that takes a timestamp, which faulted on every record #32817, fix(mcp): validate_expression refuses a relationship read both through a reference and as a plain value, as the publish gate does #32870), not regressed here. Subject to F1 and the F7 corners.
  • Can sharedValidatorRunByCaller weaken a gate run? No. runStackExpressionPasses has three callers at the head: validateStackExpressions passes {}, authoring-rules.ts:827 passes the literal { runtimeWriteType: ctx.runtimeWriteType }, and the new entry. Neither gate caller spreads caller-supplied options. The option is read with === true, and the type and the function are on none of lint's three exports entries, so no package consumer can pass it. Absent, both replaced expressions evaluate the original call unchanged.
  • Does the probe stack lose context? Extensions are already folded into a served definition; stored-metadata hops are judged from the subject's own reference fields plus a static table check, so they need no target definition; injected system columns are stripped with stripInjectedSystemColumns, the exact inverse of the governServedItem injection the served read applies, and the walk re-derives them. The differences are the F7 list.
  • Is servedObjectDefinitionOf caller-scoped? At the bridge, yes: runtime returns readObjectForCaller(name), the same masked read behind describeObject and optionFieldsOf, and mcp-object-schema-mask.test.ts adds it to the ADR-0106 case table (hidden, fault, masked field set). Stdio reads servedView.getObject. The definition is rendered to nobody and the gate's messages name only the caller's own input and the served names. At the tool, no: F1.
  • Does the pin fail when a gate pass is added? Inside its four regions as a bare call, yes; otherwise no (F3).
  • SKILL.md line and mirror. Byte-identical: the validate_expression bullet extracted from both files at the head compares equal (20 lines). True for the two sites on a bridge that serves the member, with the F1 and F2 corrections owed.
  • Lint as a runtime dependency. No cycle: lint depends on formula, metadata-core, sdui-parser, spec, none of which depends on mcp. mcp publishes dist with dependencies external, and the lockfile move is the matching six lines. Validate Package Dependencies is green. Load shape: F4.

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

VERDICT: FAIL

… load lint lazily

Hop targets the tool's object-name guard refuses are neither read nor
judged. The lint root entry is imported on first use. The enumeration pin
also reads member calls at each site and every pass the whole walk pushes,
and a source pin holds the validator-skip option to its one passer.

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

Copy link
Copy Markdown
Contributor Author

domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8a5f028fb9865775568c14a1b4faf7dbbe836cb5
Local-runs: none
Scope: round 2, delta 95b340e2..8a5f028f of the round-1 scope. Hits — MCP validate_expression at site: 'validation' / 'formula' (HTTP and stdio), @objectstack/lint root exports, McpDataBridge.servedObjectDefinitionOf, the @objectstack/mcp runtime dependency, the served SKILL.md line. Implementing files changed in the delta — packages/mcp/src/mcp-http-tools.ts, packages/mcp/src/skill-md.ts, ui/apps/console/src/pages/developer/objectstack-skill.ts, packages/lint/src/authored-site-expression.test.ts, packages/mcp/src/mcp-validate-expression.test.ts, .changeset/32872-validate-expression-gate-parity.md. Merges c00b9afc (main, #33108) and 8a5f028f (main, #33142) read for interaction only.

Read: fix commit e306a89d, classification commit bd2b25ff, the files at the head, the rewritten PR body, the round-2 os-dev-report 6863007477 on card #32872. The PR's net diff against main is still the same 19 files (+1006/−36). Check-runs on the head at 19:40Z: 67 total, 14 success, 11 skipped, 42 in progress, 0 failed. Still running: Build Core, Build Docs, Test Core 1–8/8, Test UI 1–16/16, all Type Check jobs (workspace, source gates, consumer gates, debt ledger, UI), all Lint jobs (ESLint, repo & tooling, docs & code guards, CI/release/engine, the three scoped ones, PM dispatch-gates, UI), Live E2E UI, Build & E2E UI. No test, type-check or lint verdict exists yet for this head. This PASS is the contract judgment on the diff; it does not stand in for those runs, and the seat reads them before landing.

① Derived judgments

  1. Refusals at validation and formula — right, unchanged from round 1. The entry (authored-site-expression.ts) and the gate option are byte-identical to 95b340e2; only main's fix(objectql)!: a formula reading another formula reads its computed value; a cycle of formulas is refused where it is authored #33108 adds lines to validate-expressions.ts.
  2. Hop-target disclosure — now right. mcp-http-tools.ts:gatePassFindings takes exposed, bound at the call site to guard(name) === undefined, and skips a target before bridge.servedObjectDefinitionOf(target) is called. With allowSystemObjects off, a sys_* target is neither read nor judged; with it on, it is judged, as describe_object would describe it. The declaration Clause-②: no (narrowing) is true again.
  3. Formula-cycle pass (fix(objectql)!: a formula reading another formula reads its computed value; a cycle of formulas is refused where it is authored #33108) at the formula site — right as classified. formulaCycleIssues locates each finding on a cycle member's own expression; the entry keeps only findings on the probe's where. Detail in ④.
  4. flow_condition / template — right and now stated truthfully. Unchanged verdict; the gate's own passes there are named as not run.
  5. Lint loaded lazily — right. import type at module top, await import('@objectstack/lint') inside gatePassFindings after the subject is found. Same shape as metadata-protocol's lazy root loads.
  6. Lint exports, the gate option, the optional bridge member and the runtime dependency: as judged right in round 1, unchanged.

② Semver level

  • Changeset unchanged in level: patch on @objectstack/mcp, @objectstack/lint, @objectstack/runtime, @object-ui/console; Metadata-protocol: none; BREAKING banner and ADR-0087 not-required (no-migration-prescription) kept. Correct under AGENTS.md ("the level follows the kind alone").
  • Clause-②: no (narrowing) holds with F1 fixed. The sentence now reads "no existing exported type or signature changes" in both the PR body and the changeset (F5 fixed).
  • Skills-docs: none still holds.
  • The changeset now states the hop-target fence, the lazy load and the internal gate option. Check Changeset had not completed on this head at the read.

③ Boundary flags

④ Design judgment

Round-1 findings:

  • F1 (must-fix) — fixed. A target the guard refuses is skipped before the bridge read (gatePassFindings, if (target === objectName || !exposed(target)) continue;). Paths checked: servedObjectDefinitionOf has three call sites, all in gatePassFindings; the subject is fenced by guard(objectName) at the top of the handler, each target by exposed. Stdio and HTTP share registerObjectTools, so both transports take the same fence. What remains for a fenced target: the stored-metadata hop refusal still fires for a reference into a stored-metadata table, but it is decided from the subject's own field and a static table list, with no read of the target. It reveals nothing the subject's definition does not already show. No remaining oracle found. Pinned by mcp-validate-expression.test.ts "a hop target follows the same system-object fence as describe_object" (both arms, including the list of definitions read); the dev reports the ablation red.
  • F2 (should-fix) — fixed. The false sentence is gone from the changeset, the PR body, the VALIDATE_SITE_MAP docblock and the test title. The SKILL line and the tool description qualify the unprovisioned-column warning to the two sites. The PR body claims validation and formula only and names the flow passes as a follow-up.
  • F3 (should-fix) — fixed to a stated reach. The pin now also reads member calls inside each site's regions (SITE_MEMBER_CALLS) and every issues.push(fn( / issues.push(...fn( across the whole walk (WALK_PUSHES). The test docblock and the PR body state what it still cannot see: a pass outside the regions that pushes by another shape, or one handed by reference. That residual is real and now written down, with the instruction to read the gate when merging a change to the walk. So: the pin fails for a new pass added as a bare or member call in a site region, or as a whole-pass push anywhere in the walk; it does not fail for the shapes the docblock lists.
  • F4 (should-fix) — fixed. Lazy import as above. In the CJS build a dynamic import becomes a require, which lint's exports serves.
  • F5 (nit) — fixed.
  • F6 (nit) — carried. In the Acceptance notes and the dev's follow-up item (b).
  • F7 (nit) — carried, documented. The closure, masked-field, unserved-target and picklist corners are in the Acceptance notes. The by-name subject match in judgeAuthoredSiteExpression is unchanged.
  • F8 (nit) — fixed. A source pin holds sharedValidatorRunByCaller to its declaration, its one read and its one passer, and holds runStackExpressionPasses / StackExpressionOptions off all three package entries.

New in round 2:

  • N1 — nit. "The probe is on no cycle" is not strictly true. formulaCycleIssues (formula-cycle-issues.ts) builds edges from each formula's reads of other formula fields and reports members in declaredNames; the probe is a declared formula field. If the probed expression reads record.__validate_expression__, the probe is a self-cycle and the entry returns the cycle sentence for it. No authored field has that name, so the shared validator already refuses the read as an unknown field; the effect is a second, odd sentence on an expression that is refused anyway, and the gate on the same probe stack says the same. Reword the why in SITE_CALLS.formula.formulaCycleIssues to "on no cycle unless it reads its own probe name". The classification stands: for every expression over real fields, the probe has no inbound edge (no authored formula can read it), so it is on no cycle, and a cycle among the object's own fields is located on those fields and filtered out. The dev's pin covers the case that matters (probe reads into an existing cycle, nothing located on the probe).
  • N2 — nit. The changeset title still says "every pass the gate runs there included". Two passes are silent for the tool's probe (stored-expression inputs, formula cycle) and fenced targets are not judged. The body says so; the title overstates.

No new defect found in the delta. The mirror is byte-identical at the head (the validate_expression bullet extracted from both files compares equal, 22 lines).

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

VERDICT: PASS

…object exposure census

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

Copy link
Copy Markdown
Contributor Author

domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: fbdba0708e50558dd62890d05508f4132ae9b0c3
Local-runs: none
Scope: round 3, delta 8a5f028f..fbdba070 (one commit, 2 files, +51/−0). Hit — the #22661 second-object exposure census (packages/core/src/security/second-object-read-exposure.pin.test.ts, CALLERS), red at 8a5f028f on packages/lint/src/authored-site-expression.ts as an unclassified caller of referenceTargetOf. Implementing files — that pin (one new CALLERS row) and packages/mcp/src/mcp-validate-expression.test.ts (one new behaviour pin and its fixture). No product source changes in the delta; the round-1 and round-2 scope stands as judged in records 6859189545 and 6863080029.

Read: the delta, the census pin whole at the head, the two tests its new row and the dev's reasoning cite, and the check-runs. Check-runs on the head at 19:59Z: 22 registered, 3 success, 3 skipped, 16 in progress, 0 failed. Still running: every Type Check job (workspace, source gates, consumer gates, debt ledger), every Lint job listed (ESLint, repo & tooling, docs & code guards, CI/release/engine, the three scoped ones, PM dispatch-gates), Check Changeset, Validate Package Dependencies, Auto Label, Flag docs. No Build or Test job had registered yet, so the census pin's own verdict on this head (Test Core, where it was red) does not exist. This PASS is the contract judgment on the classification; the seat reads Test Core 5/8 green on this sha before landing. My round-2 PASS did not catch this pin; CI did.

① Derived judgments

  1. authored-site-expression.ts classified not-a-served-read — right. The census asks whether a read that follows a reference serves anything of the target's records (a field value, a display name, an order, a match) without asking the target's enable block. This caller serves none of those:
    • authoredExpressionHopTargets calls referenceTargetOf only to name the target object.
    • gatePassFindings (mcp-http-tools.ts) then makes one kind of bridge call, servedObjectDefinitionOf, for the subject and each exposed target. It calls no query, get, aggregate or any other record member.
    • judgeAuthoredSiteExpression and the gate walk are pure functions over those definitions; @objectstack/lint has no bridge, engine or driver to read a row with.
    • What reaches the caller about the target is one fact per probed column: whether the target declares it.
  2. "describe_object already serves that schema for an apiEnabled: false object" — true. Both bridges answer describeObject from the same caller-scoped definition read as servedObjectDefinitionOf (readObjectForCaller in runtime/src/domains/mcp.ts, servedView.getObject in stdio-data-bridge.ts), with no enable check on either; the stdio bridge asks enforceApiExposure on its record verbs only. Pinned on both transports: mcp-server-runtime.merged-object-resources.test.ts:142 ("the one declared apiEnabled: false included") and stdio-data-bridge.merged-object-reads.test.ts:169, while the same file's line 239 pins that every record verb refuses that object. So the field-existence fact the tool gives is a subset of what describe_object hands the same caller, and the system-object fence from round 2 sits in front of both.
  3. Not fencing on enable.apiEnabled — right. The exposure decision governs data operations (get / list) on rows. Fencing a definition read on it would make validate_expression stricter than describe_object for the same object and would leave the tool ok on hops the gate refuses, for no row protected.

② Semver level

No change. The delta is two test files; nothing published moves. Changeset, Clause-②: no (narrowing), Metadata-protocol: none and Skills-docs: none stand as judged in round 2.

③ Boundary flags

④ Design judgment

  1. Is the classification true, and in the pin's vocabulary? True (①). The row uses kind: 'not-a-served-read' with a reason, the shape the pin's Classification type declares and 26 other rows use, including lint's own validate-expressions.ts ("authoring-time static analysis of metadata; reads no record") and stored-expression-inputs.ts. The reason names the functions, what is read (the definition, through servedObjectDefinitionOf), what is not (records, values), and two tests a later reader can open.
  2. Is the pin loosened? No. The delta adds one row to CALLERS and touches nothing else in the file: the arbiter scan, the unclassified and stale checks, the decided function list and the wiring checks are unchanged. The stale check still holds the new row to a file that calls the arbiter.
  3. Does the behaviour pin fail if a record read is added? For query and get, yes: the test bridge records both and the test asserts none on a call that judges an apiEnabled: false target, after asserting the target's definition was read and the hop refused. The dev reports an ablation; not re-run here. Its reach is N3.
  4. New defects. None found.

Findings:

  • N3 — nit. The record-call recorder covers query and get only. gateBridge leaves create / update / remove as makeBridge's silent stubs and defines no aggregate, while the comment says "every record read or write the tool asks the bridge for". An added aggregate call would still red the test (the member is absent, the handler's catch turns the throw into an error result), but a write stub call would pass unrecorded. Record every record member, or narrow the comment.
  • N4 — nit. The row's reason says "on every MCP transport" and cites the HTTP-side test only. Add stdio-data-bridge.merged-object-reads.test.ts, which is the stdio half of the claim.
  • N1, N2 (round 2, nits) — carried, unchanged: the formulaCycleIssues why wording, and the changeset title's "every pass the gate runs there included".

Round-1 findings F1–F8: status as recorded in round 2; nothing in this delta moves them.

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

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx

维护者速读 · PR #33134 @ fbdba070 · 2026-11-13T20:45Z

  • 改了什么:MCP validate_expression 在 validation 和 formula 两个位点,除了共享校验器,还会跑发布门禁自己的那几道检查(通过 lint 新导出的 judgeAuthoredSiteExpression)。发布时会被拒的表达式,工具现在也拒;发布时会警告的,工具也警告。
  • 为什么改:工具承诺给出发布门禁的判定,以前却只跑共享校验器。空值未守卫、跳到目标对象上未声明的字段、跳进存储元数据表、校验规则读 parent,这几类都回 ok: true,到发布时才第一次被拒(mcp: validate_expression still answers ok where the validation/formula gate refuses (null-guard, undeclared hop field, and the rest of the gate's private passes) #32872)。
  • 权限与边界:跳转目标和 describe_object 守同一道系统对象围栏。门禁只读目标对象的定义,不读任何记录,读到的定义与调用者用 describe_object 看到的一致。flow_condition 位点不在本 PR,已另立卡 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。
  • 风险与回滚:收窄的只是一个建议性工具,门禁的接受集不变;门禁接受的表达式,答案与以前逐字相同。@objectstack/mcp 把 @objectstack/lint 从 devDependency 挪到 dependency,没有形成环。回滚就是 revert。
  • 先看:packages/lint/src/authored-site-expression.ts;validate-expressions.ts 新增的 sharedValidatorRunByCaller;mcp-http-tools.ts 的 gatePassFindings。
  • 需要你做的:无,席位落地。
  • 席位意见:落地。三轮契约复审(CONTRACT_REVIEW_TIER,narrowing 面)。首轮 FAIL,必须修项是跳转目标绕过系统对象围栏;第二、三轮都 PASS。CI 首次跑红,抓出一处未分类的跨对象读取,已按分类处理并补了行为 pin;现在全绿。没有碰受管面,改动 1093 行。

席位核对

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 13, 2026 20:46
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 13, 2026
Merged via the queue into main with commit b22be27 Nov 13, 2026
68 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-wqhn19 branch November 13, 2026 21:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file 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

2 participants