Skip to content

fix(lint)!: os build and the object_extension publish door judge each validation rule an object extension adds by the object's own rule pass - #33205

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

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/project-thread-mn9za6

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread

Fixes #33154

Clause-②: no (narrowing) — os build, os validate, os lint and the runtime object_extension publish door (Studio, PUT /api/v1/meta/object_extension/TARGET, the MCP and AI authoring doors) now refuse an extension whose validation rule carries a condition or when that the same rule declared on the object is refused for. No exported type or signature changes. Measured first-party exposure: 0 extension entries carry a validation rule (examples/app-showcase and packages/plugins/plugin-auth extensions add fields only; HotCRM origin/main 92519d8 has no objectExtensions in code), so nothing first-party is newly refused. Two repository test fixtures carried the refused shape and are repaired here (see Acceptance notes).
Metadata-protocol: narrowed
Skills-docs: none owed — this PR edits content/docs/deployment/validating-metadata.mdx (the object extension door paragraph names the rule pass); skills/objectstack-data teaches validation rules and defineObjectExtension({ extend, fields }) without saying an extension's rules are exempt, so the build and the door now give the verdict the object's own rules already get.

Precedent followed: #33072 (PR #33119), which declared the same no (narrowing) / narrowed pair for the computed fields an extension adds.

维护者速读

What changes

Lint: one rule pass, two walks.

  • packages/lint/src/validate-expressions.ts.
    • The per-rule body of the object walk is hoisted into one closure, judgeValidationRule(rule, { objectName, locus, rulePath }). It covers the condition and when (with the one-hop checks the rule validator serves), every nested then / otherwise predicate, the parent root verdict and the null-guard gate. The object walk's loop now calls it, and its findings, order and key paths are byte for byte what they were.
    • lint: a stored computed field added by an object extension is never judged by the field-formula pass (stored-expression warnings and expression errors skip it) #32875's extension walk calls the same closure for each validations[] rule of each entry that objectExtensionsByTarget keeps (the fold already carried them). A record.FIELD read resolves against the target's fields as served (fieldIndex, over objectsAsServed), so a field this extension or a sibling package's extension adds resolves, and a field nothing serves is unknown. An undeclared target is judged on what does not depend on its fields, as its formulas are. The hop checks (refuseUndeclaredHopField, storedMetadataBodyReferencesFor) read the declared objects, so the leaf of a one-hop read through a reference an extension adds is not judged (see Acceptance notes).
    • The locator is object 'TARGET' · objectExtensions[i] · validation 'RULE', and the key path is ENTRY.validations[k].SLOT. The door's 422 carries that path as is (objectExtensions[0].validations[0].condition); only a draft read's _diagnostics re-spell it item-relative (validations.0.condition).
    • On an object_extension write the door's live objects are read through stripInjectedSystemColumns (@objectstack/metadata-core), as authored-site-expression.ts reads served objects. The registry serves an object with the system columns it injects (created_at, owner_id, …) declared, required: false and no default, so the null-guard gate judged record.created_at > … as an unguarded nullable operand and refused at the door what os build accepts. Stripped, the indexes re-derive those columns as the build does: known fields, never judged nullable.
    • On an object_extension write the door still runs ONE pass, the extension walk, and returns after it (lint: the object_extension publish door runs no field-formula judgment, so a computed field os build refuses still publishes through PUT /meta/object_extension #33072's extensionWrite). The rules join that one pass.
  • packages/lint/src/authoring-rules.ts. Comment only (surface amendment 6868654518), on the expression rule's runtimeTypes.

Docs, migration and changeset.

  • content/docs/deployment/validating-metadata.mdx. One sentence in the object extension door paragraph. The CEL row's columns do not change: the ᵒˣ mark landed with lint: the object_extension publish door runs no field-formula judgment, so a computed field os build refuses still publishes through PUT /meta/object_extension #33072.
  • packages/spec/src/migrations/entries/semantic/18.object-extension-validation-rule-expression-judged.ts. The ADR-0087 D3 entry. Its printed guidance carries no tracker id. There is no D2 conversion, because how a refused rule is repaired is a fact only the author holds.
  • .changeset/33154-object-extension-validation-rules-judged.md. @objectstack/lint minor and @objectstack/spec minor, with both declaration lines, a BREAKING banner (Before / Now / Unchanged / Fix) that names the null-guard refusal and its fix, and the ADR-0087 disposition marker.

Tests

  1. packages/lint/src/runtime-gate.object-formula-writes.test.ts, a new top-level describe #33154 with 9 cases, at the real gate (runRuntimeAuthoringRules) and the build (runAuthoringRules('build', …)):
    • LIT: a misspelt field is refused at the entry, with its key path.
    • LIT: an unregistered function in a nested then branch is refused at the branch.
    • PARITY: on one shared input set (a typo, sqrt, a bare reference), the door's findings deep-equal the build's.
    • PARITY: with the objects as the registry serves them (applyInjectedSystemColumns), an unguarded ordering over record.created_at is accepted at the door as at the build, and one over a declared nullable field (record.amount > 0) is refused at both, the findings deep-equal.
    • CONTROL: a valid rule, a rule reading a field the same extension adds, and a gated one-hop read are clean at both doors.
    • SIBLING: a rule reading a field a sibling package's extension adds resolves at the build, and a misspelling of it is refused at packages[2].manifest.objectExtensions[0].
    • An undeclared target is judged on what does not depend on its fields.
    • One pass: the extension write does not judge the target's own broken rule.
    • CONTROL: the object save door still judges the object's own rule at its own location.
  2. packages/runtime/src/domains/meta-object-extension-item.test.ts, a new describe [#33154] with 4 cases, through the real saveMetaItem + publishMetaItem:
    • LIT: a rule reading record.nmae gets 422 INVALID_METADATA with issue expression-invalid at the build's location and path. The rule is not served, and the row stays a draft.
    • CONTROL: a valid rule publishes and is served on the account.
    • PARITY: record.created_at > timestamp(…) with no guard publishes and is served (the account as served declares created_at), and os build (runAuthoringRules('build', …) on the account as authored and the same entry) reports nothing; an unguarded record.bonus__c > 100 on a field the same entry adds is refused at both, with the same rule, location and path.
    • LIT (republish, lint: the object_extension publish door runs no field-formula judgment, so a computed field os build refuses still publishes through PUT /meta/object_extension #33072's S1 shape): dropping a field that the extension's own rule still reads is refused, because the stored self lends it nothing.
  3. Repaired by this change (stale fixtures, each the refused shape):
    • meta-object-extension-item.test.ts: a bare region__c == null becomes record.region__c == null.
    • packages-disable-module-extension-fields.test.ts (surface amendment 6869487624): an unguarded record.qx_credit > 5000000 gains record.qx_credit != null &&.
    • authored-site-expression.test.ts: the validate_expression enumeration's source regions now read the hoisted rule pass. indexOf and isArray moved out of the region with the object loop's own index, so they leave the member-call table.

Ablation (one-shot; nothing permanent): node scripts/ablation-replace.mjs replaced the extension walk's for (const rule of rules) { with an empty loop (anchor x1 → x0, blob a3c3164d → 1bba8602). Then pnpm --filter @objectstack/lint build ran, and ablation-dist-preflight found the marker in 4 built files.

  • Lint pins: 5 of 8 red. The 3 controls stayed green.
  • Runtime pins, read through dist/: 2 of 3 red. The control stayed green.
  • Restore: the blob equals HEAD and git diff HEAD is empty. Lint was rebuilt, and ablation-dist-preflight --absent passes on all 20 built files with a clean tree.
  • The mutate leg's tree reading first exited 3, because the source spells the marker [] as typeof rules. The dist reading passed on that run, and the restore leg passed --source-marker.

Ablation of the strip (r1, one-shot): ablation-replace.mjs replaced if (extensionWrite) stack = { with if (extensionWrite && String('ablated-strip') === 'off') stack = { (anchor x1 → x0, blob 72951696 → e6b23d5a). Lint was rebuilt, and ablation-dist-preflight found the marker in 4 built files.

  • Lint pins: 1 of 9 red, the injected-column PARITY case (the door reported the null-guard finding on record.created_at, the build none).
  • Runtime door pins: 1 of 4 red, the PARITY case (422 on record.created_at).
  • Restore: blob equals HEAD and git diff HEAD is empty. Lint was rebuilt, and ablation-dist-preflight --absent passes on all 20 built files with a clean tree.

Acceptance notes

  • Out of scope, as the seat ruled (filed as An object extension's format and json_schema validation rules skip the regex and JSON-schema checks the object's own rules get #33217): two rules still judge an object's own rules only.
    • validate-rule-compilability checks a format rule's regex.
    • validate-rule-schema-formats checks a json_schema rule's formats.
    • Both read obj.validations, so a regex or schema format in an extension's rule is judged at neither door. Neither is an expression pass. The changeset's Unchanged line says so.
  • Not judged, at either door: the leaf of a one-hop read through a reference an extension ADDS. refuseUndeclaredHopField and storedMetadataBodyReferencesFor read the declared objects, so with manager__c (a lookup to staff) added by the extension, record.manager__c.daly_hours > 12 passes the build and the door alike (measured), while the same misspelling through a reference the target declares is refused. The changeset's Unchanged line says so. Filed as A one-hop read through a lookup an object extension adds is accepted with a misspelt target field, then refuses every save #33216.
  • Filed from the contract review (record 6878859677, ③): the flow and hook publish doors read the same unstripped nullable index, so an unguarded ordering over record.created_at in a record-triggered flow or a hook condition is refused at the door while os build accepts it. It is out of this card's scope: those walks are not in this diff. Filed as A flow or hook condition ordering record.created_at with no guard is refused at the publish door while os build accepts it #33246.
  • A rule an extension adds under the same name as one of the target's own rules replaces it in the registry (merged by name). Both are judged, each where it is authored.
  • Module packages registered at boot (defineStack({ objectExtensions }) through registerApp) are not gated, as an object's own rules are not. packages-disable-module-extension-fields.test.ts still registers unguarded module rules that way, untouched.

Verification

Head 9d07ffa06 (rework r1 on 15d074782: the strip, its pins, and the prose fixes). origin/main was merged twice before r1 (1169d64 before opening, then after #33168 landed on authored-site-expression.test.ts); r1 merges nothing, and git merge-tree against origin/main at push time is clean. Everything below ran at 9d07ffa06 unless it names another head.

Package tests
  • @objectstack/lint at 9d07ffa06: 208/208 files, 8879/8879 tests. Typecheck exit 0.
  • Runtime door files at 9d07ffa06 (meta-object-extension-item.test.ts + packages-disable-module-extension-fields.test.ts): 74/74.
  • Every other test file that publishes an object_extension, at 9d07ffa06: metadata-protocol protocol-extension-redeclared-owner-field, protocol-stored-item-conversion.notice-name, protocol-authoring-gate.extension-write-fold, protocol.packaged-base-refusal, protocol.unknown-picklist-binding-refusal (129/129); runtime meta-picklist-binding-door, packages-export-base-showcase-boot.integration, meta-object-extension-item-door-fit (22/22).
  • At e4ee8af8e (before r1; r1 changes only the extension door's object read and prose): @objectstack/metadata-protocol 22810 tests passed, @objectstack/objectql 12874, @objectstack/platform-objects 1955, @objectstack/mcp 1145, @objectstack/cloud-connection 1206, @objectstack/plugin-approvals 1632, @objectstack/service-mailbox 152, @objectstack/rest 8346 (332 skipped), @objectstack/cli unit 5934 and integration test/migrate-meta-engine-guidance.test.ts 3/3, @objectstack/runtime 597 files after the fixture repair.
Gates (dispatch-gates --commands, 109 lines)

The derivation at 9d07ffa06 is the same 109 lines as before r1. Every line ran with stdin closed and its exit code captured before any pipe. 103 exited 0 on the first pass; six @objectstack/spec lines (check:api-surface, check:browser-reachable-entries, check:dual-source-exports, check:entry-nameability, check:exported-any, check:skill-examples) exited 3 (refused, nothing measured) because the spec build predated the edited semantic entry. After pnpm --filter @objectstack/spec build all six exited 0. dispatch-gates --ran: 109 derived, 109 run, 0 NOT-MEASURED.

Dogfood (the title carries !), full battery, the CI command, no shard, on head 9d07ffa06. The closure was built first. Result: VERDICT command-exit 0, Test Files 463 passed and 3 skipped (466), Tests 3918 passed and 42 skipped (3960), no failures.


Generated by Claude Code

…extension adds, by the object walk's own rule pass

WIP: pins, changeset and migration entry follow.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibyToDkPxCjvNiWsv6e
… the object_extension gate and the real publish door

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibyToDkPxCjvNiWsv6e
…ild and the object_extension door

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibyToDkPxCjvNiWsv6e
…s a rule declared on the object must

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibyToDkPxCjvNiWsv6e
…; the extension rule loop adds no receiver

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibyToDkPxCjvNiWsv6e
…its nullable operand

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibyToDkPxCjvNiWsv6e
…now meets, and its fix

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibyToDkPxCjvNiWsv6e
…-mn9za6

# Conflicts:
#	packages/lint/src/authored-site-expression.test.ts
@github-actions github-actions Bot added size/l 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 14, 2026
@github-actions

github-actions Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 5 documentable anchor(s).

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

  • content/docs/deployment/validating-metadata.mdx (via AUTHORING_RULES (symbol, a top-level const object))
What this run could not see
  • 3 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 — 172 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 6e1ab9b286655c5476312ae563ab531f3f36cf38 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 6e1ab9b286655c5476312ae563ab531f3f36cf38

⚠️ 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 6e1ab9b286655c5476312ae563ab531f3f36cf38 → 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:spec#2 · os-tesla · session_01L9qibvfX5bawZKtfa6u7j9

Contract-review-claim: session_01L9qibvfX5bawZKtfa6u7j9 · head 15d0747 · 2026-11-14T03:07Z

Face: schema + narrowing (CONTRACT_REVIEW_TIER); the seat runs it through an isolated subagent.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-tesla · session_01L9qibvfX5bawZKtfa6u7j9

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 15d074782c704e5b016436ec2ac8102d5d18411c
Local-runs: none
Scope: packages/spec/src/migrations/entries/semantic/18.object-extension-validation-rule-expression-judged.ts + packages/lint/src/validate-expressions.ts, packages/lint/src/authoring-rules.ts, packages/lint/src/runtime-gate.object-formula-writes.test.ts, packages/lint/src/authored-site-expression.test.ts, packages/runtime/src/domains/meta-object-extension-item.test.ts, packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts

PR #33205 for card #33154, on the schema face (the semantic entry) and the declared narrowing. Read: the card body and its five comments (6865785642, 6868572380, 6868654518, 6869487624, 6870691534), the PR body and its nine-path file list, the net diff against main, and the head's check-runs at 2026-11-14T03:27Z: 68 runs, 58 success, 10 skipped, none failed or pending, the last completed 2026-11-14T03:05Z. Every statement about behaviour below is read from the tree at the head. Nothing was built or run.

One must-fix (F1), one should-fix (F2), three nits (N1 to N3).

① Derived judgments

  1. os build, os validate and os lint refuse a stack whose objectExtensions[].validations[] rule fails the rule pass. Right, and a narrowing. The rule's registry entry is commands: ALL (authoring-rules.ts:648), so os lint is a third CLI door. The changeset and the entry name the first two only.
  2. The runtime object_extension publish door refuses the same rule with 422 INVALID_METADATA, issue expression-invalid. Right as declared, except that the door also refuses one shape the declaration does not cover (F1).
  3. The newly refused shapes. An unknown field, a bare reference, an unregistered function, an unguarded nullable ordering or arithmetic operand, a parent root, a type mismatch, and the hop shapes a declared rule is refused for. Each is a verdict the object's own rules already get. The changeset spells four of them and covers the rest by "the same rule on the object". Right. One refused shape is not named, and is wrong: F1. One declared judgment is not delivered: F2.
  4. No exported type or signature moves in @objectstack/lint. Right. judgeValidationRule is a closure inside runStackExpressionPasses, and StackExpressionOptions changes in its docblock only.
  5. @objectstack/spec gains one semantic entry in the protocol 18 step, so os migrate meta prints one more block. Right, additive.
  6. A new locator. object 'T' · ENTRY · validation 'N' (nested: validation rule 'N' then → 'M'), key path ENTRY.validations[k].SLOT, under the object's own rule id. Right.
  7. The object walk is unchanged. Right. Compared line by line, the hoisted body differs from the inline one on main in three places, each object '${objectName}' becoming ${locus}, and the object call site passes locus as that same string.
  8. Not gated, as before: a module package registered at boot, and the package-wide draft publish limit the options docblock already states. As declared.

② Semver level

The changeset moves @objectstack/lint and @objectstack/spec at minor. Both are released packages the diff moves (lint source, one spec migration entry), and the kind narrowed takes at least minor, so the level is right. packages/runtime changes in tests only and publishes nothing, so it owes no entry.

The Clause-②: no (narrowing) line and Metadata-protocol: narrowed stand line-initial on the PR body and on the changeset body. The changeset carries the BREAKING banner, the fix, and exactly one ADR-0087 marker, registered object-extension-validation-rule-expression-judged, which is the entry's id. Check Changeset and Lint · CI, release & engine gates are green on the head. The arm is the right arm. The bound it states, "a condition or when the same rule on the object itself is refused for", is exceeded at the door (F1).

③ Boundary flags

  • Dev report open_questions: empty.
  • Dev report deviations: the attribution line links the card thread, and the ablation's mutate leg needed --source-marker. Neither touches the contract. Accepted.
  • Dev report out-of-scope finding (a format rule's regex and a json_schema rule's formats in an extension's validations are judged at neither door): confirmed by reading. Both rules walk obj.validations only (validate-rule-compilability.ts:429). It is a trap of this card's own class, so a card is owed. Escalated to seat 2, the carrier the report names. The changeset's Unchanged line states it.
  • The claim's provisional Metadata-protocol: narrowed: stands.
  • Amendment 6868654518 (comment only in authoring-rules.ts): held. Seven comment lines; runtimeTypes and the adapter are untouched.
  • Amendment 6869487624 (the repaired fixture; the banner and the entry must name the null-guard refusal and its fix): held. Both name it, with the guard.
  • File surface: the nine paths are the claimed surface plus the two amendments. No breach.
  • New from this review, escalated to seat 2: F2 needs a follow-up card. The flow and hook passes read the same nullable index at their own doors (validate-expressions.ts:3045, :4255). That is outside this diff and not measured; if F1's measurement confirms the served form there, it is a card too.

④ Design judgment

What was tried against the design, and held:

  • A rule reading a field another extension adds. It resolves at the build through fieldIndex over objectsAsServed, and at the door through the live target. packages-disable-module-extension-fields.test.ts is a real-door instance: the tenant pair's rule reads qx_credit, which the module's extension adds.
  • A republish judged without the package's stored self. objectExtensionWriteFold takes the written package's extension off the target, and the republish case pins it: dropping bonus__c while the rule still reads it is refused.
  • The one-pass rule. The object walk and the flow walk read an empty list on an extension write, and the function returns at validate-expressions.ts:4136. Pinned by the case where the target's own broken rule is not charged to the extension write.
  • Rule id and locator. expression-invalid, located at the entry, pinned at both doors.
  • Tracker ids in printed guidance. None (see the entry below).

packages/spec/src/migrations/entries/semantic/18.object-extension-validation-rule-expression-judged.ts (hits)

  • The four printed fields, surface, replacement, reason and acceptanceCriteria, hold no # followed by digits. The one id sits in the leading comment, which is not printed. That is the line packages/cli/test/migrate-meta-engine-guidance.test.ts holds every entry to.
  • No D2 conversion and no relevantWhen: both right. The repair is a fact only the author holds, and the surface also lives in stored object_extension rows a stack does not show.
  • Its premise holds against the registry: mergeValidationsByName (packages/objectql/src/registry.ts:1160) merges an extension's rules onto the target by name.
  • It does not hold against the door for one input: its replacement (lines 23 to 28) and acceptanceCriteria (lines 38 to 41) are false there. See F1.
  • N3 below.

packages/lint/src/validate-expressions.ts (implementing)

The hoist is faithful (① item 7). The extension loop passes the target, the entry locator and the rule's key path, and the return after the walk is intact. Two findings on the contract, two nits.

F1, must-fix. At the real object_extension door the null-guard gate reads the registry's injected system columns as nullable declared fields, so the door refuses a rule that os build and the object's own rule pass accept.

  • Mechanism. The door hands the walk its live objects: registry.listItems('object') folded with the stored rows (packages/metadata-protocol/src/protocol-authoring-gate.ts:437-474). A registered base layer carries its injected columns in fields, because applySystemFields runs before contributor storage (packages/objectql/src/registry.ts:5890-5895). Each is defined required: false with no defaultValue (packages/spec/src/data/injected-system-column-provenance.ts:61-97). Nothing between the registry and the walk strips them. nullableIndex is built from those objects (validate-expressions.ts:2379, isNullableField at :493), and the new loop sends each extension rule through checkNullGuards for the target (:3641, :4126).
  • Effect. An extension rule with an unguarded ordering or arithmetic operand over record.created_at or record.updated_at (or any other injected column) is refused 422 at the door.
    • os build accepts the same entry, because the declared target carries no injected column. The index's own docblock (:508) says it holds declared fields only, so that a record.created_at comparison does not become an error.
    • The object save door accepts the same rule declared on the object, because its subject is the body stripped at protocol-save.ts:582.
  • The tree already states this. packages/lint/src/authored-site-expression.ts:105-111 strips the injected columns off registry-served definitions before this same pass, and says why: unstripped, the null-guard gate would judge created_at as a declared nullable field. The extension door does not strip. One consequence is that the MCP validate_expression tool answers clean for a rule the extension publish then refuses.
  • Why it is a contract fault. On an update the stored row always holds created_at (the index's docblock: the driver always populates it), so for a rule that runs on update the refusal is false. Whether the column is already bound when an insert rule runs was not traced; if it is not, the door is right there and the build under-covers. Either way the door and the build disagree, and three shipped sentences are false for this input:
    • the entry's replacement ("A rule the expression rule accepts on a declared rule of the same object") and its acceptanceCriteria (a clean os build, then a 200);
    • the declared bound on the changeset and the PR body;
    • the docs sentence "at the door and in os build alike".
  • Why the pins do not see it. The lint PARITY case gives the door an authored-shaped target, and the three real-door cases read name, nmae and a field the extension adds. The type index (:2378) has the same served-form difference, there in the truthful direction.
  • Basis. Read, not run. One real-door case settles it: an extension rule with an unguarded ordering operand over record.created_at, published through saveMetaItem and publishMetaItem, beside os build of the same entry.
  • Remedy, the dev's choice. Either judge the door's objects as authored on an extension write (the strip judgeAuthoredSiteExpression uses), so the door gives the build's verdict; or keep the door's verdict and name it in the entry, the changeset and the docs sentence. Either way, pin it at the real door.

F2, should-fix. A one-hop read through a reference field an extension adds has its leaf unjudged, so the card's trap stays open in that corner.

  • refuseUndeclaredHopField (:2447, the loop at :2458) and storedMetadataBodyReferencesFor (:2386) read the reference field's definition off the declared objects, not off the served fold.
  • So an extension that adds a lookup, and a rule that reads a misspelt column through it and is otherwise well formed, is accepted at the build. At the door it is accepted when the lookup is the written entry's own. By the helper's own docblock, such a rule then "has no verdict and refuses every write it judges".
  • The helper predates this PR and a formula has the same gap. But the new comment at :4111-4114 and the changeset say the rule is judged "against the target as served, through the same indexes", which is wider than what is enforced. A lookup and a rule in one extension is the natural shape on this surface.
  • Remedy without moving a reviewed file: add this to the changeset's Unchanged line, and file a follow-up card. No case pins it today.

N1, nit. The key path's index is a counter over recordsOf(ext.validations) (:4123-4129, object-graph.ts:451). On input ObjectExtensionSchema refuses, the path is wrong: a non-record member ahead of the rule shifts the index, and a map-form validations gets an index where the object walk spells no path. This is reachable only on an unparsed stack (os lint). The locator and the verdict are unaffected. Accepted deviation or a follow-up.

N2, nit. The comment at :4116-4118 says the door re-spells the path item-relative (validations.0.condition). The door's 422 keeps objectExtensions[0].validations[0].condition, as this PR's own real-door case pins. The item-relative spelling is itemRelativeIssuePath's, where a draft read reports the publish verdict. The hoisted docblock at :3576-3580 says it correctly.

N3, nit, in the hits file. Two loose sentences in the entry's reason:

  • It locates the finding at objectExtensions[0] for the build and the door alike. The build locates at the entry's own index, under packages[J].manifest. in an artifact, as the lint sibling case pins. acceptanceCriteria already spells [N].
  • "a rule reading a nullable field with no guard aborted" is wider than the gate, which judges ordering and arithmetic operands only. An equality read is accepted: record.region__c == null is this PR's own repaired fixture.

Fix both with F1's edit to the entry, or leave them as accepted deviations.

packages/lint/src/authoring-rules.ts (implementing)

Comment only, as amended. Its measured sentence holds on reading: examples/app-showcase ships one entry with three plain fields, plugin-auth one entry with fields only, and neither carries a rule.

The four test files (implementing)

  • runtime-gate.object-formula-writes.test.ts: the eight cases assert what the PR body says. Two blind spots: no door case over a registry-shaped target (F1), and no hop through a reference the extension adds (F2).
  • authored-site-expression.test.ts: the regions follow the hoist. indexOf and isArray stay in the object loop, outside the rule pass, so they rightly leave the table.
  • meta-object-extension-item.test.ts: three real-door cases, and the bare region__c fixture repaired to record.region__c.
  • packages-disable-module-extension-fields.test.ts: the tenant rule guarded, as the amendment says.

Outside the diff, read only: the object_extension row comment in packages/lint/src/runtime-gate.ts:293-296 still describes the crossing as the extension walk over the written entry's fields. A follow-up.

Implemented-by: claude/project-thread-mn9za6
Reviewed-by: session_01L9qibvfX5bawZKtfa6u7j9

VERDICT: FAIL


Generated by Claude Code

…columns

The object_extension door hands the objects as the registry serves them,
with the injected system columns (created_at, owner_id, ...) declared, so
the null-guard gate judged record.created_at as a declared nullable field
and refused an extension rule os build accepts. On that write each object
is now read through stripInjectedSystemColumns, as the authored-site
expression judge does. Pinned at the gate and the real door (accepted at
both; a declared nullable field still refused at both).

Comments, the changeset and the semantic entry now say that the leaf of a
one-hop read through an extension-added reference is not judged, that the
door's 422 keeps the stack path, that os lint narrows too, and that the
null-guard gate judges ordering and arithmetic operands only.

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

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-tesla · session_01L9qibvfX5bawZKtfa6u7j9

Contract-review-hold: session_01L9qibvfX5bawZKtfa6u7j9 · head 9d07ffa · the maintainer paused lane seats' own contract reviews; reviews go to the new review agent · until a review agent is seated and takes this head

Handover · PR #33205 (card #33154) · 2026-11-14T04:39Z

The seat signs off by the maintainer's instruction. The PR stays a draft and is not accepted. Nothing is armed.

  • Review state: record 6870963898 FAILED head 15d07478 with one must-fix (F1), one should-fix (F2) and three nits (N1 to N3). The claim on 15d07478 lapses with the head. The delta review of 15d07478..9d07ffa0 was started and then stopped under the pause. It produced no record, so a delta review of 6870963898 is owed on 9d07ffa0. The face is schema + narrowing at CONTRACT_REVIEW_TIER.
  • What r1 says it did (the implementer's account, not a verdict):
    • F1 was measured real: the door returned 422 on an unguarded record.created_at ordering read while build returned []. The fix reads the door's objects through stripInjectedSystemColumns, as authored-site-expression.ts does. It adds PARITY pins in lint and on the real door; ablating the strip turns lint 1/9 and runtime 1/4 red.
    • F2 was measured real and fixed on the prose faces only.
    • N2 and N3 are fixed. N1 stays as an accepted deviation.
    • The PR body was patched to r2 by the seat; the read-back is identical.
  • Check-runs on 9d07ffa0 at hand-over: 56 success, 12 skipped, 2 in progress, and Test Core (2/8) + the Test Core aggregate failed. The failures are 4 tests in packages/spec/scripts/build-schemas-check-mode.{deletion-proofs,git-topology}.test.ts, each Hook timed out in 10000ms in a beforeEach (:224). No file in this diff is in those tests' path. One re-run is owed before reading it as this PR's; the seat did not re-run it.
  • Follow-up cards filed from the review: A one-hop read through a lookup an object extension adds is accepted with a misspelt target field, then refuses every save #33216 (F2: a one-hop leaf through an extension-added lookup is unjudged) and An object extension's format and json_schema validation rules skip the regex and JSON-schema checks the object's own rules get #33217 (an extension's format / json_schema rules skip the regex and schema checks). One more is not filed because it is unmeasured: the flow and hook passes may read the same unstripped nullable index at their own doors (validate-expressions.ts:3045, :4255, per 6870963898). The next reviewer measures it or files it.
  • Landing after a PASS: the lane seat's ACCEPT (the changeset and mdx prose at TIER_DEFAULT), then label-write --land. Not governed: 504 lines at 15d07478.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-tesla · session_01L9qibyToDkPxCjvNiWsv6e

Contract-review-claim: session_01L9qibyToDkPxCjvNiWsv6e · head 9d07ffa · 2026-11-14T14:19Z

Face: schema + narrowing (CONTRACT_REVIEW_TIER). The hold 6871853372 is lifted: the maintainer restored seats' own contract reviews ("你现在可以自己契约复审了", project chat, 2026-11-14 14:17Z). This session implemented the PR, so the review runs in an isolated subagent at CONTRACT_REVIEW_TIER that is fed the diff and record 6870963898, not the implementer's conclusions.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-tesla · session_01L9qibyToDkPxCjvNiWsv6e

Re-run, once: Test Core (2/8) on head 9d07ffa069baf93055b4da43e19fccbf9237159c (run 43554881417, job 132325981683) failed in 4 tests of packages/spec/scripts/build-schemas-check-mode.deletion-proofs.test.ts and build-schemas-check-mode.git-topology.test.ts. Each failure is Hook timed out in 10000ms in a beforeEach, with no assertion failing. The failures measured nothing. Neither file reads a path this PR changes: the diff touches packages/lint/src, the runtime door tests, one semantic migration entry, a changeset and one mdx page, and these tests build throwaway git repositories under packages/spec/scripts. The @objectstack/spec unit project in the same job passed 839 of 840 files, with 1 skipped. I judge it a flake and re-run the failed jobs once through rerun-failed.mjs. A second failure counts as real.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-tesla · session_01L9qibyToDkPxCjvNiWsv6e

The brief handed to the isolated reviewer at CONTRACT_REVIEW_TIER for the record below it, posted beside the record per references/contract-review.md.

Contract review brief — PR #33205 @ 9d07ffa069baf93055b4da43e19fccbf9237159c (card #33154)

You review at CONTRACT_REVIEW_TIER. Render ONE ## Contract review record on this head — its shape is
node scripts/pm/record-recognisers.mjs --template --paths packages/spec/src/migrations/entries/semantic/18.object-extension-validation-rule-expression-judged.ts — with a PASS or FAIL verdict, and post it as ONE comment
on the PR or its card.

Inputs — these and nothing else:
· card #33154: its body and EVERY comment (rulings and earlier records included)
· PR #33205: its body, its file list, and the net diff against main at 9d07ffa069baf93055b4da43e19fccbf9237159c
· the check-runs on 9d07ffa069baf93055b4da43e19fccbf9237159c: their conclusions ARE the gate verdicts
⛔ Not the dispatch order, ⛔ not the dispatching seat's own conclusions — this brief is adversarial by design.

Scope — the paths reviewFace returns as hits on this PR's file list: packages/spec/src/migrations/entries/semantic/18.object-extension-validation-rule-expression-judged.ts;
plus every file IN THIS DIFF that directly implements or enforces them — the gate that enforces a rule, the code
a jsdoc or schema line describes, the validator a skill teaches — reviewed for agreement with the contract.
Widen it freely, ⛔ never below hits; the record's Scope: line names the implementing files apart from hits.
The rest of the diff (tests, unrelated modules, lockfiles, generated output, changesets) is the lane seat's
ACCEPT at TIER_DEFAULT, exactly as a standalone PR of those files.
④ judges the hits files and those implementing files ONLY:
lockfiles, generated output, changesets and prose elsewhere in the PR are not reviewed;
code outside the diff is READ to judge them, never reviewed.

⛔ READ-ONLY. No worktree, no checkout, no build, no test run, no gate re-run, no ablation: the check-runs
on the head already answered every derived gate family, and a local run costs more than it returns.
If ONE probe is unavoidable, the record's Local-runs: line says probe — REASON; otherwise none.
Scratch space is /tmp/claude-0/-home-user/f1414190-0e98-53df-b144-bdabf7a7b94d/scratchpad/issue-33154/review/pr-33205/ alone; nothing there you did not write this round is read.

Judge, in the record's own sections:
① Derived judgments — every accept-set and public-surface change the diff implies, each named right or wrong.
② Semver level — the changeset (or skip-changeset) matches what the diff publishes, and the Clause-②: line.
③ Boundary flags — every dev flag and every open_questions entry answered, or escalated.
④ Design judgment — for EVERY hits file: does the change hold against the code it cites and the
scenario it is accepted against? A skill file against the card's eval prompt and the spec it teaches
on main; a schema file against the ruling or ADR it implements and its first consumer; an ADR
against its first consumer and its execution plan. For EVERY implementing file in the diff: does the
code agree with the contract, sentence by sentence? Read the cited code in the live tree, and try to
break the design.
Grade each finding must-fix, should-fix or nit — a nit has no effect on correctness or the contract.
A must-fix is a FAIL; a must-fix or should-fix earns a delta review of its fix; nits alone end the round.
A nit is fixed only on the prose faces, or left as an accepted deviation or a follow-up card: a fix
anywhere else moves the head, and this record no longer covers it.
⛔ Judge the tree, never the PR body's account of it.
Sign Implemented-by: with the dev's branch and Reviewed-by: with your session id — the value is the FIRST
thing after each colon.

Card-thread notes (this round's context; inputs, not conclusions):
· The PR body and changeset also declare Clause-②: no (narrowing), so the narrowing face applies too: the scope floor is every path of the diff outside the prose faces (.changeset/**, content/docs/**), as the earlier record's Scope: line had it.
· This head is a rework. The earlier full review of head 15d074782c704e5b016436ec2ac8102d5d18411c is PR #33205 comment 6870963898 (VERDICT: FAIL; F1 must-fix, F2 should-fix, N1 to N3 nits). Its author is no longer seated, so this round is a full review of the new head that also judges, finding by finding, whether F1, F2, N2 and N3 are resolved in the TREE (N1 was left as an accepted deviation). Read every PR #33205 comment as well as the card's: the record, the seat's hand-over 6871853372 and the claim and re-run notes.
· The hand-over 6871853372 names one unmeasured question: whether the flow and hook passes read the same unstripped nullable index at their own doors (validate-expressions.ts near the flow and hook walks). Judge it by reading the tree. Say whether it is in this PR's scope, and if it is not, whether it warrants a follow-up card.
· The follow-up cards #33216 and #33217 exist; read them.
· Read GitHub with gh api (read-only GET calls only) and the tree with git show 9d07ffa069baf93055b4da43e19fccbf9237159c:PATH / git diff origin/main...9d07ffa069baf93055b4da43e19fccbf9237159c in /home/user/objectstack-issue-33154 (read only; do not check out, build or edit anything there).
· Do NOT post anything to GitHub. Write the finished record (the template's shape, from ## Contract review down to the VERDICT line) to /tmp/claude-0/-home-user/f1414190-0e98-53df-b144-bdabf7a7b94d/scratchpad/issue-33154/review/record.md, and return it in full as your final message. The adopting session posts it. Sign Implemented-by: with claude/project-thread-mn9za6 and Reviewed-by: with session_01L9qibyToDkPxCjvNiWsv6e (the session that adopts and posts your verdict); the record states, under ③, that the session which implemented the PR adopts a verdict rendered by an isolated subagent at this tier, by the maintainer's word of 2026-11-14 14:17Z that seats may run their own contract reviews again.
· Write every finding so a reader with no context can act on it: file and line, the input, what happens, what should.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-tesla · session_01L9qibyToDkPxCjvNiWsv6e

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 9d07ffa069baf93055b4da43e19fccbf9237159c
Local-runs: none
Scope: packages/spec/src/migrations/entries/semantic/18.object-extension-validation-rule-expression-judged.ts + packages/lint/src/validate-expressions.ts, packages/lint/src/authoring-rules.ts, packages/lint/src/runtime-gate.object-formula-writes.test.ts, packages/lint/src/authored-site-expression.test.ts, packages/runtime/src/domains/meta-object-extension-item.test.ts, packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts

PR #33205 for card #33154, on the schema face (the semantic entry) and the declared narrowing. This is a full review of the rework head. It also judges, finding by finding, whether F1, F2, N2 and N3 of the earlier record 6870963898 (head 15d07478, FAIL) are resolved in the tree.

Read: the card body and its six comments; the PR body, its nine-path file list and its six comments (the earlier record, the hand-over 6871853372, the two claims and the re-run note among them); the net diff against main; cards #33216 and #33217; and the head's check-runs. Every statement about behaviour below is read from the tree at the head with git show. Nothing was built or run.

No must-fix and no should-fix. Four nits (N4 to N7). One follow-up card is owed (the flow and hook doors, under ③).

Check-runs at 2026-11-14T14:28Z: 71 runs: 58 success, 12 skipped, 1 in progress. The one without a conclusion is Test Core (2/8), attempt 2 (job 132475937503, started 14:21Z). Attempt 1 (job 132325981683) failed. Its log, read for this record, shows 4 failed tests, each Hook timed out in 10000ms in a beforeEach of packages/spec/scripts/build-schemas-check-mode.deletion-proofs.test.ts (:224) and build-schemas-check-mode.git-topology.test.ts (:104). Those hooks write files into a throwaway git sandbox and read no path of this diff. In the same job the @objectstack/spec unit project, which holds the migration-entry tests, passed 839 of 840 files with 1 skipped. No lint or runtime test runs in that shard, and the other seven shards are green. So no gate that judges this contract is red. ⛔ This verdict does not stand in for that shard: landing waits for its conclusion, and a second failure is real and is not covered by this record.

① Derived judgments

  1. os build, os validate and os lint refuse a stack whose objectExtensions[].validations[] rule fails the rule pass. Right, and a narrowing. The rule's registry row is commands: ALL (authoring-rules.ts:648). The changeset now names all three commands.

  2. The runtime object_extension publish door refuses the same rule with 422 INVALID_METADATA, issue expression-invalid. Right, and now within the declared bound (F1 below).

  3. The newly refused shapes: an unknown field, a bare reference, an unregistered function, an unguarded nullable operand of an ordering or arithmetic operator, a parent root, a type mismatch, and the hop shapes a declared rule is refused for. Each is the verdict the object's own rule gets, because both walks call one closure. Right.

  4. New on this head: on an object_extension write the door's objects are read through stripInjectedSystemColumns (validate-expressions.ts:2377). This moves door verdicts in three places, each to the build's verdict:

    • The null-guard gate no longer judges an injected column (created_at, owner_id, ...) as a declared nullable field. This is the F1 fix, and a widening of what the door refused on 15d07478.
    • The type index no longer types an injected column at the door (buildFieldTypeIndex, :432, declared fields only). A type verdict over record.created_at that the door gave since lint: the object_extension publish door runs no field-formula judgment, so a computed field os build refuses still publishes through PUT /meta/object_extension #33072 on an extension's formula field, and that the build never gave, is gone. A widening at the door.
    • The hop-leaf check (refuseUndeclaredHopField, :2474) no longer treats an injected lookup (created_by, owner_id) as a declared reference of the target, so the leaf of record.created_by.FIELD is unjudged at the door as it is at the build. A widening at the door.

    All three are right against the contract, which is "the build's verdict" in this entry and in lint: the object_extension publish door runs no field-formula judgment, so a computed field os build refuses still publishes through PUT /meta/object_extension #33072's entry. The changeset names the first. The other two are covered by its sentence that the door reads the objects "as the build does" (N6).

  5. No exported type or signature moves in @objectstack/lint. Right. judgeValidationRule is a closure inside runStackExpressionPasses. StackExpressionOptions changes in its docblock only. The new import, @objectstack/metadata-core, is already a dependency of the package (packages/lint/package.json:54) and is already imported by authored-site-expression.ts:45 and runtime-gate.ts:666.

  6. @objectstack/spec gains one semantic entry in the protocol 18 step. Right, additive. Entries are concatenated from the directory, so the file needs no index edit.

  7. The object walk is unchanged. Right. The hoisted body (:3583 to :3660) differs from the inline body on main in three places, each object '${objectName}' becoming ${locus}, and the object call site (:3686) passes locus as that same string and the same rulePath.

  8. Not gated, as before: a module package registered at boot, and the package-wide draft publish limit the options docblock states. As declared.

② Semver level

The changeset moves @objectstack/lint and @objectstack/spec at minor. Both are released packages the diff moves (lint source, one spec migration entry). The kind narrowed takes at least minor, so the level is right. packages/runtime changes in tests only and owes no entry.

Clause-②: no (narrowing) and Metadata-protocol: narrowed stand line-initial on the PR body and on the changeset body. The changeset carries the BREAKING banner (Before, Now, Unchanged, Fix) and exactly one ADR-0087 marker, registered object-extension-validation-rule-expression-judged, which is the entry's id. Check Changeset (both runs) and Lint · CI, release & engine gates are green on the head. The arm is the right arm, and the bound it states ("a condition or when the same rule on the object itself is refused for") now holds at the door for the input that broke it on 15d07478.

③ Boundary flags

  • Who renders and who adopts. The session that implemented this PR (session_01L9qibyToDkPxCjvNiWsv6e) adopts and posts this verdict. It was rendered by an isolated subagent at CONTRACT_REVIEW_TIER, fed the brief, the diff, the threads and record 6870963898, and not the implementer's conclusions. This rests on the maintainer's word of 2026-11-14 14:17Z that seats may run their own contract reviews again (claim 6878678135, which lifts the hold 6871853372).
  • Dev report open_questions: empty.
  • Dev report deviations: the attribution line links the card thread, and the ablation's mutate leg needed --source-marker. Neither touches the contract. Accepted.
  • Dev report out-of-scope finding (an extension's format regex and json_schema formats are judged at neither door): filed as An object extension's format and json_schema validation rules skip the regex and JSON-schema checks the object's own rules get #33217, read, triaged p2. The changeset's Unchanged line states it. Closed here.
  • Record 6870963898 F2 (the leaf of a one-hop read through a reference an extension adds): filed as A one-hop read through a lookup an object extension adds is accepted with a misspelt target field, then refuses every save #33216, read, triaged p2. See ④ for the tree side.
  • The claim's provisional Metadata-protocol: narrowed: stands.
  • Amendment 6868654518 (comment only in authoring-rules.ts): held. Seven comment lines; runtimeTypes and the adapter are untouched.
  • Amendment 6869487624 (the repaired fixture; the banner and the entry name the null-guard refusal and its fix): held.
  • File surface: the nine paths are the claimed surface plus the two amendments. The rework commit touches five of them and adds none. No breach.
  • The hand-over's unmeasured question: do the flow and hook passes read the same unstripped nullable index at their own doors? By reading the tree: yes. It is not in this PR's scope. It warrants a follow-up card. Escalated to the adopting seat to file.
    • Mechanism. On every runtime write the gate hands the rules registry.listItems('object') folded with the stored rows (packages/metadata-protocol/src/protocol-authoring-gate.ts:437 to :474). A registered object carries its injected columns in fields (applySystemFields, packages/objectql/src/registry.ts:5890), each required: false with no defaultValue (packages/spec/src/data/injected-system-column-provenance.ts:61). packages/lint/src/runtime-gate.ts strips nothing. This head strips only under extensionWrite (validate-expressions.ts:2377). So on a flow or hook write nullableIndex (:2395) holds created_at, updated_at, created_by, updated_by, owner_id and organization_id as nullable fields.
    • Flow door. On a record-triggered flow the start, edge and decision conditions go through checkNullGuards (:3061), and every name in that index also joins the bare-name set (:3047 to :3053). Input: a record-triggered flow whose condition is record.updated_at > record.created_at, or the bare created_at > timestamp('2020-01-01T00:00:00Z'). What happens, by reading: the publish door refuses it with the null-guard error, and os build of the same flow reports nothing, because the build's index holds declared fields only (the index's own docblock, :509). What should happen: one verdict at both.
    • Hook door. A hook condition goes through checkNullGuards at :4278 and :4310. Same input shape, same split.
    • A second consumer disagrees with the door. judgeAuthoredSiteExpression (authored-site-expression.ts:235) strips before it judges a flow_condition site as "a flow write at the runtime publish gate". So the MCP validate_expression tool answers clean for a condition the flow publish then refuses.
    • Why it is out of scope. The flow and hook walks, their doors and runtimeTypes are not in this diff, and the card is about an extension's validation rules. The split predates this PR.
    • Basis and the card's first step. Read, not run. One real-door case settles each: publish a record-triggered flow, and a hook, whose condition applies an ordering operator to record.created_at with no guard, beside os build of the same item. The card's remedy question is which side is right per column: the index's docblock says the driver always populates created_at while owner_id really can be NULL.
  • Carried from record 6870963898, still open, outside the diff: the object_extension row comment in packages/lint/src/runtime-gate.ts:293 to :296 still says the crossing is the extension walk "over the written entry's fields". It now judges the entry's rules too. A follow-up edit; it can ride A one-hop read through a lookup an object extension adds is accepted with a misspelt target field, then refuses every save #33216 or An object extension's format and json_schema validation rules skip the regex and JSON-schema checks the object's own rules get #33217, which touch the same walk.

④ Design judgment

The earlier record's findings, judged in the tree at this head.

  • F1 (must-fix): resolved.
    • The fix is the first remedy the record offered: validate-expressions.ts:2377 reads each object through stripInjectedSystemColumns on an extension write, before any index is built. buildFieldIndex (:247) then re-adds the injected names through injectedColumnsFor, as it does at the build, so record.created_at stays a known field. buildNullableFieldIndex (:570) sees declared fields only, so the gate gives no verdict on it. The walk's fieldDefs re-derive the injected definitions through injectedColumnDefsFor(base) (:4105).
    • The strip is exact by construction. stripInjectedSystemColumns removes a field only when it is identical, key for key, to the platform's definition (isInjectedColumnDefinition, injected-system-column-provenance.ts:237), and applySystemFields spreads those definitions unchanged (its docblock at registry.ts:1458 forbids adding keys). A created_at the author declared in any other shape stays the author's field at the door and at the build alike.
    • It is pinned where the record asked. Lint: the PARITY case that feeds the door applyInjectedSystemColumns(target) and deep-equals its findings with the build's, for record.created_at > timestamp(...) (accepted at both) and record.amount > 0 (refused at both). Real door: the PARITY case in meta-object-extension-item.test.ts asserts the registry serves created_at, publishes the rule through saveMetaItem and publishMetaItem, and compares a refused record.bonus__c > 100 with the build's rule, location and path. Both files run in green shards.
    • The three sentences that were false for this input are now true: the entry's replacement and acceptanceCriteria, the declared bound, and the docs sentence "at the door and in os build alike".
    • What the record left untraced stays untraced and is not this PR's: whether created_at is bound when an insert rule runs. If it is not, the build under-covers for an object's own rules exactly as for an extension's. The contract here is parity with the object's own rule, and parity holds.
  • F2 (should-fix): resolved on the faces the record named. The tree does not judge the leaf, and says so in three places: the walk's comment (:4132 to :4136), the options docblock (:2275), and the changeset's Unchanged line, with the example record.manager__c.nmae. Card A one-hop read through a lookup an object extension adds is accepted with a misspelt target field, then refuses every save #33216 carries the fix. The sentence the record called too wide ("against the target as served, through the same indexes") is gone. No case pins the gap; A one-hop read through a lookup an object extension adds is accepted with a misspelt target field, then refuses every save #33216 owns that.
  • N1 (nit): left as an accepted deviation, as the hand-over says. Unchanged at :4146 to :4154. It is reachable only on an unparsed stack, and the verdict and locator are unaffected.
  • N2 (nit): resolved. The comment at :4139 to :4141 now says the door's 422 carries the path as is and only a draft read's _diagnostics re-spell it item-relative. That agrees with itemRelativeIssuePath (packages/metadata-protocol/src/metadata-diagnostics.ts:401) and with the real-door LIT case, which pins objectExtensions[0].validations[0].condition on the 422.
  • N3 (nit, in the hits file): resolved. The reason now gives objectExtensions[N] with N the entry's own index, the packages[J].manifest. prefix in a built artifact, and objectExtensions[0] at the door. The null-guard sentence is narrowed to "an ordering or arithmetic operator" and says an equality such as == null is accepted.

packages/spec/src/migrations/entries/semantic/18.object-extension-validation-rule-expression-judged.ts (hits)

Read sentence by sentence against the tree.

  • surface: defineObjectExtension exists (packages/spec/src/data/object.zod.ts:5781) and the fold reads ext.validations (packages/lint/src/object-graph.ts:451). Right.
  • replacement: every clause is a verdict of judgeValidationRule, and "a rule the expression rule accepts on a declared rule of the same object" now holds at both doors. The clause "reads one hop through a reference only as a declared rule may" does not promise the leaf check F2 names. Right.
  • reason: the premise holds (mergeValidationsByName, packages/objectql/src/registry.ts:1160). The locator matches object-graph.ts:448 and the walk's locus. A nested branch is located objectExtensions[N] · validation rule 'R' then → 'M', which the acceptance sentence's prefix objectExtensions[N] · validation still matches.
  • acceptanceCriteria: a clean os build and then a 200 at the door. Right for every input I could construct. Attempts that did not break it: an injected column (F1, fixed); a field a sibling extension adds (resolved at both, pinned); a target outside the door's package closure (judged only on what does not depend on its fields, the accepting direction); a republish that drops a field its own rule reads (refused, pinned).
  • The four printed fields hold no tracker id. No D2 conversion and no relevantWhen: right, for the reasons its leading comment gives.
  • N5 below.

packages/lint/src/validate-expressions.ts (implementing)

The code agrees with the contract. The hoist is faithful (① item 7). The extension loop (:4146 to :4154) passes the target, the entry locator and the rule's key path. The return after the walk (:4159) keeps the door at one pass. The strip sits after the view and page early returns and before the first index, and it is fenced to extensionWrite, so the build and every other door read stack.objects as before.

Tried against the strip, and held:

  • A served object whose fields is not a record: the strip returns it untouched, and the door's objects are registry-shaped records.
  • A stored (overlay-backed) target, which may arrive without injected columns: the strip is a no-op on it.
  • The written extension's own fields: they are folded in by objectsAsServed after the strip, so a nullable field the extension adds is still judged (pinned at the real door with bonus__c).
  • A deployment that declares the target platform-global: the registry injects no organization_id, and the one-argument plan the strip and injectedColumnsFor use would still call the name known. That is the accepting direction, it predates this PR, and no ordering or arithmetic rule plausibly reads that column. Not a finding.

N4, nit. validate-expressions.ts:3683. The comment reads "The object's index in the authored list, for the finding's key path", but the line under it computes the rule's index in the object's validations (ruleIndex). The object's index is the comment at :3671. No effect on behaviour. Accepted deviation, or fixed with the next edit of the walk (#33216 or #33217).

N5, nit, in the hits file. The entry's leading comment (lines 5 to 6) names os build, os validate and the door. The rule is commands: ALL, so os lint refuses too, as the changeset says. The comment is not printed. Accepted deviation.

N6, nit, prose face (changeset). The Now paragraph names one effect of the strip: created_at is not judged nullable. It does not name the other two in ① item 4, which also move #33072's door verdict on an extension's formula field (no type verdict on an injected column; no leaf verdict through an injected lookup). Both are widenings to the build's verdict, so nothing an author must do follows from them. One clause would cover it. A changeset edit moves the head, so it is the lane seat's call: accepted deviation is enough.

N7, nit, prose face (PR body). The PR body's Clause-②: line names os build and os validate; the changeset's names os lint as well. Fix the PR body to match, which moves no head.

packages/lint/src/authoring-rules.ts (implementing)

Comment only, as amended. Its measured sentence holds on reading: examples/app-showcase/src/data/extensions/account.extension.ts and packages/plugins/plugin-auth carry extensions with fields only, and no non-test source under examples/ or packages/plugins/ declares a rule on an extension.

The four test files (implementing)

  • runtime-gate.object-formula-writes.test.ts: nine cases, each asserting what its title says. The new PARITY case closes the blind spot the earlier record named for F1. Still unpinned, by design: a hop through a reference the extension adds (A one-hop read through a lookup an object extension adds is accepted with a misspelt target field, then refuses every save #33216).
  • meta-object-extension-item.test.ts: four real-door cases, and the bare region__c fixture repaired to record.region__c. The PARITY case first asserts the precondition (the registry serves created_at), so it cannot pass vacuously.
  • authored-site-expression.test.ts: the source regions follow the hoist. judgeFieldFormula (:3486) precedes judgeValidationRule (:3583), so the formula region's new end marker is right, and indexOf and isArray stay in the object loop, outside the rule pass.
  • packages-disable-module-extension-fields.test.ts: the tenant rule is guarded, as the amendment says.

Implemented-by: claude/project-thread-mn9za6
Reviewed-by: session_01L9qibyToDkPxCjvNiWsv6e

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-tesla · session_01L9qibyToDkPxCjvNiWsv6e

维护者速读 · PR #33205 @ 9d07ffa0 · 2026-11-14T14:37Z

  • 改了什么:对象扩展(objectExtensions / Studio 发布的 object_extension)新增的校验规则,现在在 os build、os validate、os lint 和运行时发布口都用对象自身校验规则的同一套检查判定(judgeValidationRule,规则 id expression-invalid),位置为 object '对象' · objectExtensions[N] · validation '规则'。扩展发布口读对象时会先去掉平台注入的系统列,与 build 读法一致。
  • 为什么改:An object extension's validations are judged at neither the publish door nor os build, yet the registry registers them on the target object #33154。注册表按名字把扩展的规则合并到目标对象上,每次保存记录都会执行,但此前两道门都不判;字段拼错、函数不存在、可空字段没加守卫,都要等最终用户保存时才失败。
  • 风险与回滚:收窄。第一方实测 0 处扩展校验规则受影响;仓库里 2 个测试夹具正是被拒的形状,已修正。草稿保存不受影响,已存的行要等再次发布才判。回滚就是 revert。
  • 先看:packages/lint/src/validate-expressions.ts 里的 judgeValidationRule、扩展遍历新增的循环,以及 extensionWrite 下去掉注入列的那一行(:2377)。
  • 需要你做的:无,席位落地。
  • 席位意见:落地。现 head 契约复审 PASS(6878859677),无 must-fix / should-fix;CI 全绿;非受管面,579 行。

席位核对


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · sunhaolin · session_017xRBcSbLCxEmyHf9upz3pz

维护者速读 · PR #33205 @ 9d07ffa0 · 2026-11-14T15:28Z

  • 改了什么:对象扩展新增的校验规则,现在在 os build / os validate / os lint 和运行时发布口,都按对象自身规则的同一套检查判定。扩展发布口读对象时先去掉平台注入的系统列,与 build 的读法一致。
  • 为什么改:An object extension's validations are judged at neither the publish door nor os build, yet the registry registers them on the target object #33154。扩展的规则每次保存记录都会执行,但此前两道门都不判,写错要等最终用户保存时才暴露。
  • 风险与回滚:收窄。第一方实测 0 处受影响;草稿保存不受影响。回滚即 revert。
  • 先看:packages/lint/src/validate-expressions.ts 的 judgeValidationRule 与扩展遍历循环。
  • 需要你做的:无,本席落地。
  • 席位意见:落地。契约复审 PASS 在现 head 上;CI 全绿;非受管面;579 行。

席位核对

ACCEPT. This is the seated domain:spec#2's verdict; the card thread's ACCEPT 6878959095 is an input and is not repeated here.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 14, 2026 15:28
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 14, 2026
Merged via the queue into main with commit 27d042f Nov 14, 2026
118 of 120 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-mn9za6 branch November 14, 2026 15:57
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/l 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.

An object extension's validations are judged at neither the publish door nor os build, yet the registry registers them on the target object

2 participants