Skip to content

fix(lint)!: the object save door gives the build's field-rule-slot verdict (#22032 pass 2) - #22117

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22032-object-door-field-rules
Oct 7, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22032-object-door-field-rules

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #22032
Clause-②: no (narrowing)

This is pass 2 of #22032: the field-rule slots. Passes 3 and 4 stay fenced, and the card stays open for them: option visibleWhen, and the object's action predicates.

What changes

The object save door gives the build's verdict on a field's rule slots. formulas.mdx says "the same validateExpression validator backs os build and metadata registration". After pass 1 the object door judged formula fields and validation-rule predicates, and fenced the field-rule slots off by name. So an object whose field carried a bare requiredWhen: 'amount > 1' (the card's measured body) still saved with a 200, while os build refused it at error.

  • The change is in the fence, not the registry. In runStackExpressionPasses (packages/lint/src/validate-expressions.ts) the field walk no longer starts with if (objectWrite) { judgeFieldFormula(fname, f); continue; }. On an object write the walk now runs, at the build's own position:
    • the four slots (requiredWhen, readonlyWhen, conditionalRequired, visibleWhen) as record-scoped predicates, with the root verdict;
    • the parent gate (a readonlyWhen / requiredWhen reading parent on an object without exactly one master_detail);
    • the null-guard check over requiredWhen;
    • the refusal of a requiredWhen / readonlyWhen read through a reference field;
    • the formula pass, unchanged.
  • Pass 3 stays fenced by its own guard. H1 held: the removed continue also skipped the per-option visibleWhen loop, which sits inside the same iteration. That loop now reads (objectWrite ? [] : recordsOf(f.options)), the same guard shape as the flow, action, sharing-rule and hook loops. Deleting the continue alone would have lifted passes 2 and 3 together.
  • No registry change (H3 re-verified). The validateStackExpressions entry declares runtimeTypes: ['flow', 'action', 'hook', 'object'] (authoring-rules.ts), and runtimeAuthoringRulesFor('object') (runtime-gate.ts) dispatches it. runtime-gate.ts is untouched.
  • Docblocks made true (H2). StackExpressionOptions.runtimeWriteType now names three admitted passes and two fenced ones. AuthoringRuleContext.runtimeWriteType in authoring-rules.ts, the one line that reaches a built .d.ts, names the field-rule-slot pass. The function-head comment, the judgeFieldFormula docblock, the registry entry's measurement comment and the object roster comment in runtime-gate.object-writes.test.ts move with it.
  • The door's verdict is the build's finding (H4). The door's 422 issue and runAuthoringRules('build', …) give the same rule (expression-invalid), location (object 'fx_field' · field 'name' requiredWhen), path, message and hint. The pins compare these key by key.
  • No code change in packages/metadata-protocol. Only its test file gains the door-level pins.

Pins

  • Lint door: packages/lint/src/runtime-gate.object-field-rule-writes.test.ts (new, 12 tests).
    • LIT, one refused body per slot and per gate: a bare requiredWhen, an unregistered function in readonlyWhen and in visibleWhen, a bare conditionalRequired, the root verdict (current_user), the parent gate, the requiredWhen null guard, and the traversal refusal (record.account.name). Each is located at the slot and asserted on its named subject.
    • CONTROL: valid predicates on every slot, and a parent-scoped detail with its master stored beside it, are clean at the door and at the build.
    • PARITY: for each refused body, the door's findings equal the build's.
    • The differential: a stored sibling's broken field rules are not this write's to answer for.
  • The fence (enumeration pin): in packages/lint/src/runtime-gate.object-formula-writes.test.ts. The fenced sites are now passes 3 and 4 only (an option visibleWhen, an action visible). The lifted sites are the validation rule and the requiredWhen. The build flags all four; the object door flags the two lifted sites, in the build's order, and runStackExpressionPasses on an object write returns exactly the build's findings for the admitted passes.
  • Protocol door: a new pass-2 block in packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts, through the real saveMetaItem, publishMetaItem and publishPackageDrafts:
    • (a) a bare requiredWhen, an unregistered function in visibleWhen, and a parent read with no master in readonlyWhen are each refused on an active save with a 422 INVALID_METADATA carrying the build's located finding, and nothing lands;
    • (a) the card's body is refused on a draft's promotion, and on a package draft publish (outcome: 'refused', failed naming the object with INVALID_METADATA, the row left a draft); the draft saves themselves still succeed;
    • (b) valid predicates on the three slots still save, and the row lands active;
    • (d) for each refused body, the door and os build give the same finding on rule, where, path, message and hint.

Reverse verification (one-off, from committed HEAD ab17f41aa)

  • What was mutated. scripts/ablation-replace.mjs --hold put the fence back at the head of the field loop: const ablationFence22032 = objectWrite; if (ablationFence22032) { judgeFieldFormula(fname, f); continue; }. The anchor was hit once, 1 to 0, and the blob went 758396f8e4e4 to 0f007b9ed735. The script carried trap restore EXIT INT TERM.
  • Rebuild and dist proof. @objectstack/lint was rebuilt, and ablation-dist-preflight found the marker in 4 built files.
  • Lint suites (source): 11 failed, 18 passed, as predicted. Red: the 8 LIT tests, PARITY, and the two fence tests that assert the lifted sites. Green: the registry test, CONTROL, the differential, the build-flags-each-site test, the six formula-door tests and the eight pass-1 tests.
  • Protocol pass-2 block (dist-mediated): 6 failed, 1 passed, as predicted. Red: the three (a) saves, (a) on promotion, (a) on package publish, and (d). Green: (b).
  • Restore. The tool restored the file: blob 758396f8e4e4 equals HEAD, and git diff HEAD is empty. Lint was rebuilt, and --absent found the marker gone from all 14 built files with a clean tree. Both suites went green again: lint 29 of 29, and the protocol file 86 of 86.

Measurements

  • Corpus first: the stop condition was not met. Every object this tree ships was judged before the door changed: every *.object.ts under packages/** and examples/** (111 files), plus the two app-multi-package sub-stacks. That is 118 objects in 18 groups, at the raw shape and at the ObjectSchema.parse shape, each with its own group as context.
    • 9 field-rule slots on 8 fields of 3 objects: showcase_invoice 4 (issued_on.requiredWhen, tax_rate.readonlyWhen, paid_on.requiredWhen, paid_on.visibleWhen), showcase_invoice_line 4 (product, quantity and unit_price readonlyWhen read parent; description.requiredWhen), and sys_permission_set.name.readonlyWhen.
    • At base bafb58bb0: 0 build errors and 0 build warnings for the pass (raw, parsed, and through runAuthoringRules('build')), and 0 door findings.
    • At head: 0 door errors and 0 door advisories over every object, through runRuntimeAuthoringRules with type object, at both shapes.
    • The card's body, as a positive control in the same harness: 1 build error and 1 door error.
  • Which doors newly answer 422 (H5). The active publish save, a draft's promotion, and a package draft publish, measured through the real methods above. A draft save stays ungated, measured by the same pins.
  • conditionalRequired cannot reach this gate through the save door. Measured through the real saveMetaItem with a scratch test that was deleted afterwards: the per-type spec step refuses it as a key retired in protocol 17 (422 INVALID_METADATA, on a draft save and on a publish save) before the gate runs. The lint pin judges it because the build does.

Clause-② (measured)

  • Accept set: narrowing. An object write in publish mode answered 200 for a field-rule slot the validator refuses. It now answers 422 on the three doors above.
  • Built entry declarations. In @objectstack/lint one doc comment moves (AuthoringRuleContext.runtimeWriteType). StackExpressionOptions and runStackExpressionPasses are not in the built declarations. No exported signature moves.
  • Changeset. .changeset/22032-object-save-door-field-rule-slots.md covers @objectstack/lint and @objectstack/metadata-protocol: minor, fix(lint)!, BREAKING, with the remedy, and ADR-0087 not-required (no-migration-prescription). .changeset/pre.json is absent on origin/main bafb58bb0, so minor with the BREAKING banner, as pass 1.

Tests and gates (all at ab17f41aa)

  • @objectstack/lint: 122 files, 5658 tests passed; typecheck exit 0, its test-typecheck included (--listFiles shows the three touched lint test files in the tsconfig.test.json program).
  • @objectstack/metadata-protocol: 221 files passed and 3 skipped; 28234 tests passed and 19 skipped; typecheck exit 0 (--listFiles shows the door test file in the program).
  • Consumer readings, against a rebuilt rest^... and objectql^... closure.
    • @objectstack/rest: every meta-object-* file and meta-publish-package-scope, 11 files, 197 tests passed.
    • @objectstack/objectql: save-meta-response-conformance, publish-meta-response-conformance and plugin.integration, 3 files, 67 tests passed.
    • No other test fixture saves a field-rule slot through a save door: every test file carrying one of the four slot keys was grepped against the door entry points, and the hits are the lint and metadata-protocol files above, whose suites are green.
  • Gates. dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 63 commands at this head. All 63 end at exit 0, and --ran reconciles 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN, with an exit code recorded for each.
    • check-plugin-teardown-shape.mjs --self-test first exited 3: its pinned fixture commit was outside this shallow clone. After git fetch --depth=1 of that one commit it exited 0.
    • check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET: no full build). After pnpm build (72 tasks, 71 cached) it exited 0.
  • ESLint, narrowed to the 6 touched TypeScript files (--no-inline-config --format json): 6 files, 0 errors and 0 warnings. Each file is matched by eslint.config.mjs (--print-config), none was ignored, and the config enables no type-aware linting (no parserOptions.project), so this diff cannot move the verdict on any untouched file.

Acceptance notes

  • A master save can be refused for a stored detail's field rule. Measured through the real saveMetaItem with a scratch test that was deleted afterwards. The stored detail fx_detail has one master_detail to fx_master, and a readonlyWhen reading parent.acct.name, where acct is a lookup on the master. Re-saving the master with only its label changed answers 422, with the issue located at object 'fx_detail' · field 'qty' readonlyWhen. Without the detail, the same save resolves.
    • Cause: the gate's differential baseline leaves out the written object's stored self, so a context finding that needs the written object present is charged to that write. The traversal refusal's parent holder needs the master's field types.
    • The property is not new to this pass. validateObjectFieldRefs on main does the same: re-saving a master is refused for a stored detail whose lookup's lookupColumns names a column the master lacks.
    • The corpus has no such detail. The detail is already refused by os build and faults at runtime. The changeset states the consequence and the remedy. It is reported on the card for the seat.
  • Boundary. The door judges a detail's parent.REF.FIELD read only when the master is in the write's context; a detail saved without its master in the package closure gets no traversal verdict there, where the build, holding the whole stack, gives one. The door stays a subset of the build.
  • Out of this PR. The nested then / otherwise predicate gap (lint: a conditional validation rule's nested then / otherwise predicate is never validated, so os build passes and the object save door stores a predicate the top-level rule would refuse #22042) is not addressed here; it is serial behind this pass. formulas.mdx could name the object save door: a docs addition, not a false line.
  • Contract review. Triage's grade asks for one per pass. It is the seat's, from an isolated subagent at the contract-review tier, and is not attached here.

Generated by Claude Code

claude added 4 commits October 7, 2026 16:11
…rdict

runStackExpressionPasses no longer skips the field-rule slots on an object
write: requiredWhen / readonlyWhen / conditionalRequired / visibleWhen run
with their parent gate, the requiredWhen null guard and the reference
traversal refusal, at the build's own position in the field walk. The
per-option visibleWhen loop keeps its own fence (pass 3), as do the
object's action predicates (pass 4).

Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848
Co-authored-by: Claude <noreply@anthropic.com>
…the fence pin to passes 3-4

Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848
Co-authored-by: Claude <noreply@anthropic.com>
…real save, promotion and package publish doors

Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848
Co-authored-by: Claude <noreply@anthropic.com>
…ect save door judges field-rule slots

Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 7, 2026
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 4 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
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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 — 4 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 ace0a53852c4f34547eca8df973faa9fb8d68c97 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json ace0a53852c4f34547eca8df973faa9fb8d68c97

⚠️ 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 ace0a53852c4f34547eca8df973faa9fb8d68c97 → 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

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ab17f41aa77ff026bffaed596c81f4ca6723d043
Local-runs: none

PR #22117 (card #22032, pass 2 of 4), head ab17f41aa7, 4 commits, 7 files (+456/−50) against merge base bafb58bb0; origin/main read at a543e244f, 10 commits past that base. Inputs: the card's body and its ten comments (grade 6024268378, unlock 6025250992, pass 1's claim, report, accept, release-line note and landing 6026005435 / 6026974644 / 6027107690 / 6038034831 / 6038933793, the pass-2 claim 6041573889, the os-dev-report 6044342067, and the seat's ACCEPT 6044395604, read as a claim under test and not as evidence); pass 1's contract review 6027097001 (it lives on PR #22041, not on the card as the brief says — read by its id); the PR body, file list and net diff against main; the head's check-runs; and origin/main plus the head fetched into a private ref of the checkout and read with git show. Nothing was built, run or re-run; the posting tool's own --dry-run is not a gate.

① Derived judgments

  1. The accept set narrows at one seam, and the diff says where — RIGHT. packages/lint/src/validate-expressions.ts, runStackExpressionPasses: the field walk's opener if (objectWrite) { judgeFieldFormula(fname, f); continue; } (base :1988) is deleted, and the per-option visibleWhen loop that the continue used to skip gains its own guard, (objectWrite ? [] : recordsOf(f.options)) (head :2041). Every other guard is unchanged under objectWrite: stack flows (:1535), stack actions (:2197), object actions (:2200), sharing rules (:2214), hooks (:2229). runtime-gate.ts and the registry entry's code are untouched (runtimeTypes: ['flow', 'action', 'hook', 'object'] as before); the entry already declared object, so no registry change was needed and none was made.

  2. Exactly which object-write inputs the door newly refuses — RIGHT, and the list is the build's own. On an object write the walk now runs, at the build's position and with the build's calls, over each field's requiredWhen / readonlyWhen / conditionalRequired / visibleWhen: (i) check(where, raw, objectName, 'record', …) — syntax error, unknown function, a field the object does not declare, a bare field reference (the one the card measured: a bare amount comparison in requiredWhen); (ii) fieldRuleRootVerdict — a root a field-level rule never binds (current_user), error; (iii) the parent gate (readonlyWhen / requiredWhen reading parent on an object with other than exactly one master_detail), error; (iv) checkNullGuards(… requiredWhen …, 'fail-closed') — an ordering or arithmetic operator on a nullable operand with no != null guard, error; (v) refuseFieldTraversal on requiredWhen / readonlyWhen reading through a reference field (record.REF.FIELD, previous.REF.FIELD, and parent.REF.FIELD when the master is in the snapshot), error. Warnings from (i) ride as advisories through the entry's severity ?? 'error' mapping, unchanged. conditionalRequired is judged at the lint level because the build judges it, and cannot reach the gate through the door: FieldSchema carries it as retiredKey(…) (protocol 17), so the per-type spec step refuses it first — the changeset's Unchanged bullet states this. The eight LIT bodies in runtime-gate.object-field-rule-writes.test.ts cover (i) through (v), one each, located at object 'fx_field' · field 'name' SLOT and asserted on the named subject.

  3. Which doors answer 422, and drafts ungated — RIGHT. The one gate is assertRuntimeAuthoringRules in packages/metadata-protocol/src/protocol.ts, which returns [] when evt.state !== 'active', on the package-author channel, and for source === 'migrate-stored'. It is called from saveMetaItem in publish mode (:20628) and from promoteDraftForPublish (:22026 + :166), through which both publishMetaItem (:21908) and publishPackageDrafts (:23275) promote. So the active publish save, a draft's promotion and a package draft publish move from 200 to 422 INVALID_METADATA for a slot the validator refuses; a draft save stays ungated. Pinned through the real methods in the protocol test's pass-2 block: three (a) refusals on an active save with nothing landed, (a) on promotion after the draft save resolved, (a) on package publish (outcome: 'refused', failed[] naming the object, the row left draft), (b) valid slots land active. OS_ALLOW_UNLINTED_METADATA_WRITES=1 (runtime-authoring-gate.ts:117) still degrades a refusal to a logged write, as the changeset says.

  4. The door's issue equals the build's on rule, where, path, message and hint — RIGHT, by construction and by pin. The registry entry (authoring-rules.ts) changes in comments only; one run body maps where, path, message and hint the same way for runAuthoringRules('build') and the gate, and the lifted slots run at the build's own position in the field walk. The expression rule's path is its where string, so nameKeyFindingPath leaves it alone at the door. Pinned key by key (rule, where, path, message, hint) in protocol test (d) for the three door bodies, and by toEqual(atBuild) in lint PARITY for all eight bodies, each non-vacuous (the build refuses every one).

  5. Per-option visibleWhen (pass 3) and actions[] predicates (pass 4) stay off the door, and the enumeration pin names exactly them — RIGHT. The option loop's new guard keeps both its check and its refuseFieldTraversal off an object write; the object-action loop keeps its guard. In runtime-gate.object-formula-writes.test.ts the pin body carries one fault per site — validation amount_root (pass 1), field 'name' requiredWhen (pass 2), field 'tier' option 'gold' visibleWhen (pass 3), action 'fx_close' visible (pass 4) — plus a clean formula. The build flags all four; the door's error where list equals LIFTED_SITES (passes 1–2) exactly and in the build's order, advisories empty, and runStackExpressionPasses(stack, { runtimeWriteType: 'object' }) equals the build's findings filtered to the admitted sites. FENCED_SITES is now the two pass-3/4 sites and nothing else. Deleting the continue alone would have lifted pass 3 silently; the dev's H1 caught it and the guard is the same shape as the five sibling loops.

  6. Order and completeness on an object write are now the build's. At base the door ran the formula FIRST (inside the opener) while the build ran it last; at head judgeFieldFormula is called at one point only, after the slots, the (fenced) options, the parent gate, the null guard and the traversal refusal, in both modes. The six formula: the metadata save door stores a formula that calls an unregistered function (sqrt), and every read answers null with nothing logged — the docs say the shared validator backs metadata registration #22019 formula-door tests and the eight pass-1 tests are reported green at the head, consistent with findings filtered by where.

  7. No public surface moves; the .d.ts-reaching sentence is true. validateStackExpressions(stack) keeps its signature; StackExpressionOptions and runStackExpressionPasses are not re-exported by src/index.ts (:66) or src/runtime.ts, so the docblock there stays internal. AuthoringRuleContext is re-exported (index.ts:951), and its runtimeWriteType sentence — an object write "is admitted for its field-formula pass and (finding(lint): the object save door gives no build verdict on validation conditions, field-rule slots (requiredWhen etc.), option visibleWhen or action predicates; os build refuses them, a metadata save stores them (#22019's sibling) #22032) its validation-rule and field-rule-slot passes alone" — is exactly what the guards in judgment 1 implement. The pre-PR sentence would read false after the lift, so the correction belongs to this diff.

  8. The corpus claim is consistent with a read of the tree. git grep over *.object.ts under packages/** and examples/** at origin/main a543e244f finds exactly 9 slot lines in 2 files: examples/app-showcase/src/data/objects/invoice.object.ts 8 (invoice: issued_on.requiredWhen using in, tax_rate.readonlyWhen, paid_on.visibleWhen + requiredWhen; invoice_line: product / quantity / unit_price readonlyWhen reading parent.status, description.requiredWhen comparing record.quantity, where quantity is required: true) and plugin-security/src/objects/sys-permission-set.object.ts 1 (name.readonlyWhen, guarded with != null). Every read is record.- or parent.-qualified, no read goes through a reference field, parent.status reads a select, not a reference, and the one ordering comparison in a requiredWhen is on a required field — so 0 refusals at the build and at the door is what the diff's own CONTROL and PARITY pins predict for such bodies. The 0-count itself is the dev's measurement and is not re-run here. The dev's "118 objects in 18 groups" against pass 1's "17 groups" is the same 118 objects with the two app-multi-package sub-stacks counted as two.

  9. The dev's open question — a master save refused (422) for a stored detail's parent.REF.FIELD field rule — RULING: this pass may land with the consequence documented, and it is documented. The mechanism, read in code: buildRuntimeWriteSnapshots (runtime-gate.ts:649) builds the baseline as every context collection with the written object's stored self filtered out, and the candidate as that baseline plus the new body. The field walk builds its parent holder only when the master is in the stack (masterTypes = fieldTypeIndex.get(master), head :1944–:1948), so a stored detail's parent.acct.name read is judged in the candidate (master present) and not in the baseline (master absent): the finding is "added" and charged to the master's write. Measured against the gate's stated contract — runtime-gate.ts "the gate blocks new writes, never stored rows" ([runtime/metadata] 作者时规则只存在于 CLI:Studio/REST/MCP 的运行时授权面是第四扇门,26 条规则一条不跑——#4409 修完后最大的敞口 #4463 D4) and reference-integrity-suite.ts:343 "a stored object already in violation is never charged to someone else's write" — this IS a mis-attribution the contract text excludes. Why it may land: (a) the class is the baseline's, not this pass's — validateObjectFieldRefs already charges a master save for a stored detail's lookupColumns / lookupFilters naming a column the master lacks, by the same mechanism (the reference-addressed slots judge "when the snapshot's objects carries it", reference-integrity-suite.ts), so the contract text is already over-broad on main, and the fix — a baseline that carries the stored self for sibling findings while still judging the written item absolutely — is one change to the differential every object-door rule reads, which the brief says is filed as finding(lint): the runtime gate's object-write baseline drops the written object's stored self, so a master save is refused (422) for a stored detail's violation — the gate's "never charged to someone else's write" contract #22118 (not read here; outside the inputs); a per-member carve-out at this door would be a second policy beside the gate's one differential and a dialect between the door's call and the build's, which the card's contract (the build's call at the build's position) excludes; (b) exposure needs a stored detail that os build already refuses at error and that already faults at runtime — readonlyWhen fails closed (the field locked on every write) and requiredWhen refuses the write (ADR-0137 D2) — so no correct master is refused for a correct detail; the finding is located at the detail's slot, which names the right fix, and the detail's own re-save is not charged (its master is context in both passes); the shipped corpus has 0 such details (judgment 8); (c) the consequence is stated to consumers in the changeset's third BREAKING bullet with its remedy ("Fix the detail's predicate, then save the master again") and in the PR's acceptance notes, and the escape hatch stands. What would have failed this: an undocumented consequence, a class this pass introduced, or a correct master refused for a correct detail — none holds. Owed, not blocking: the narrowing of the over-broad contract sentence in runtime-gate.ts belongs with finding(lint): the runtime gate's object-write baseline drops the written object's stored self, so a master save is refused (422) for a stored detail's violation — the gate's "never charged to someone else's write" contract #22118's fix of the mechanism, not with a pass that changes neither.

  10. The door stays a subset of the build. With the master outside the write's context (absent, or outside the runtime gate: full runtime-safe rule snapshot for the publish door — the expensive half split out of the object-gating card #9612 package closure) the detail's parent.REF.FIELD read gets no verdict at the door where the build, holding the whole stack, gives one; no admitted check depends on a referenced object's presence in a way that could produce a finding the build would not. The PR's Boundary note states it.

② Semver level

.changeset/22032-object-save-door-field-rule-slots.md: "@objectstack/lint": minor, "@objectstack/metadata-protocol": minor, title fix(lint)!: …, Clause-②: no (narrowing), a BREAKING — what moves for consumers block (the 200 → 422 move on the three doors of ①3, the build's refusal list, the master-save consequence), a Remedy, an Unchanged block, and exactly one ADR-0087 marker, not-required (no-migration-prescription).

  • Level — RIGHT. git ls-tree origin/main .changeset/ at a543e244f lists 47 entries and no pre.json, so Changesets is not in pre-release mode and check-changeset-no-major enforces: a major is refused outside pre-mode (no allow-major label), and "a declared narrowing graded minor is clean — the arm asks for the grade, it does not refuse the PR" (the gate's own self-test text). A narrowing is BREAKING (AGENTS.md Post-Task Checklist 3) and ships minor with the BREAKING banner, as pass 1 and formula: the metadata save door stores a formula that calls an unregistered function (sqrt), and every read answers null with nothing logged — the docs say the shared validator backs metadata registration #22019 did. skip-changeset would be wrong: the published rule body changes and one .d.ts sentence moves. Check Changeset on the head: success, twice.
  • Clause-②: line — RIGHT. Line 2 of the PR body and the changeset both read Clause-②: no (narrowing); no yes, no (widening). The claim 6041573889 spelled it Clause-②: no without the arm; the gate reads "a no stands the axis down UNLESS it carries the narrowing arm", so the bare spelling would have stood the breaking axis down, and the grade's spelling (6024268378) is the right one. The dev declared the difference (deviation 1).
  • @objectstack/metadata-protocol at minor with no code change there — RIGHT. Its package.json depends on @objectstack/lint (workspace:*), and the behaviour that moves is observable only through its exported doors (saveMetaItem, publishMetaItem, publishPackageDrafts, and REST's PUT /api/v1/meta/object/:name over them), so the BREAKING text belongs in the CHANGELOG an upgrading consumer of that package greps. Every package in .changeset/config.json sits in one fixed group, so the level is uniform whichever packages the file names; what the entry decides is where the sentence lands. Same form as pass 1 (record 6027097001, merged) and formula: the metadata save door stores a formula that calls an unregistered function (sqrt), and every read answers null with nothing logged — the docs say the shared validator backs metadata registration #22019.
  • ADR-0087 disposition — RIGHT. no-migration-prescription is refused only when the body carries a framed rewrite (a Migration / 迁移 heading, an arrow FROM → TO form, or a two-column table); the Remedy paragraph is prose naming what the message already names, with no arrow and no table, and nothing authorable, exported or stored moves. The marker argues the other four categories closed on facts, and they are (both packages publish; no ADR-0087 id covers the door; a door verdict is not a declaration).

③ Boundary flags

From the os-dev-report 6044342067 (eight deviations, one open question, two out-of-scope findings) and the PR's acceptance notes:

  • D1, Clause-② spelling — answered (②). The grade's no (narrowing) is the one the gates read; the claim's bare no was the wrong spelling for a narrowing.
  • D2, base bafb58bb0 two commits past the claim's 3d9188502e — answered. origin/main is now ten commits past the merge base (ac9f8bd47 … a543e244f). None touches validate-expressions.ts, authoring-rules.ts, runtime-gate.ts, the three lint test files or the door test; ace0a5385 (feat(lint)!) touches packages/lint/src/data-model-rules.ts only; a543e244f touches protocol.ts at :43 (an import) and :23711–:23884 (package revert), none of the gate's methods. The PR reads mergeable: true; the queue merges main and re-runs the required set on that generation (Multi-agent discipline §7, §10).
  • D3, the shallow clone deepened by one commit for a self-test fixture — answered. Local environment; no effect on the diff.
  • D4, @objectstack/metadata-protocol listed minor — answered (②).
  • D5, 18 groups vs pass 1's 17 — answered (①8). Same 118 objects; the sub-stacks counted as two.
  • D6, commit trailers — answered. All four commits (65d37ffc8, 9f12c9105, 0dc2ff8e6, ab17f41aa) carry Claude-Session: and Co-authored-by: Claude with no model identifier — the repository's model-free pair, which outranks the harness reminder.
  • D7, contract review not attached by the dev — answered. This record is it.
  • D8, the PM's mid-run probe and backgrounded builds read to their verdict — answered. Process; no effect on the diff.
  • Open question 1 (land now, or hold for the gate's baseline) — ruled in ①9: A, land as it is, with the consequence documented as it is; the mechanism fix and the contract-sentence narrowing go with finding(lint): the runtime gate's object-write baseline drops the written object's stored self, so a master save is refused (422) for a stored detail's violation — the gate's "never charged to someone else's write" contract #22118.
  • OOS-1 (class a: the gate's object-write baseline charges a master save a stored detail's finding — lookupColumns on main, parent.REF.FIELD through this pass) — escalated and, per the brief and the seat's ACCEPT, filed as finding(lint): the runtime gate's object-write baseline drops the written object's stored self, so a master save is refused (422) for a stored detail's violation — the gate's "never charged to someone else's write" contract #22118. Not read here (outside the inputs); the seam named by the dev — buildRuntimeWriteSnapshots, whose baseline[key] is the collection filtered on o.name !== itemName — is verified in ①9.
  • OOS-2, formulas.mdx "Build-time validation" could name the object save door — answered. A docs addition; no published line reads false. formulas.mdx:14 names the conditional rules among what the shared validator covers, and that now holds at the door for the field-rule slots; the remainder (option visibleWhen, action predicates) is the card's open passes 3–4. validating-metadata.mdx names the registry's runtimeTypes as the authority, which did not move.
  • The nested then / otherwise gap (lint: a conditional validation rule's nested then / otherwise predicate is never validated, so os build passes and the object save door stores a predicate the top-level rule would refuse #22042) is serial behind this pass on the same file and is not addressed or overclaimed here.
  • Check-runs on the head, as read at 2026-10-07T18:51Z: 37 runs. 30 success — among them Lint & Repo Gates (the check:* family, check:adr-0087-registration included), Build Core, Check Changeset (×2), Governed Surface Queue Guard, TypeScript Type Check and its four legs (source gates, consumer gates, debt ledger, workspace), Dogfood Regression Gate (rollup and 1/3–3/3), Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Test Core (2/6) to (6/6), Check PR Size, and the claim, single-writer, closing-target and part-of guards; 6 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke ×2, and the second Auto Label / Check PR Size legs — path-filtered); 1 in_progress — Test Core (1/6). No failure. Recorded as read, not awaited: in_progress is not a pass, and the landing leg still needs that last required context success on this head; this record does not pre-empt it.
  • Governance: the file list touches no governed surface (.changeset/**, packages/lint/**, packages/metadata-protocol/**); Governed Surface Queue Guard: success. Changed lines 506, far under the 5,000 ceiling. Draft, base main, Part of #22032 first line, no closing keyword, assignee mirrors the card's.

Implemented-by: claude/issue-22032-object-door-field-rules
Reviewed-by: session_01RPo7FUd6bSnAfkWMAKi848

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 7, 2026 19:10
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 7, 2026
Merged via the queue into main with commit f2a45db Oct 7, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22032-object-door-field-rules branch October 7, 2026 19:43
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants