Skip to content

fix(lint): the runtime gate's object-write baseline keeps the written item's stored self - #22133

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-22118-gate-baseline-stored-self
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-22118-gate-baseline-stored-self

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22118
Clause-②: no (the fix restores the gate's published contract: runtime-gate.ts "the gate blocks new writes, never stored rows" and reference-integrity-suite.ts "a stored object already in violation is never charged to someone else's write"; the refusal it removes is one that text already denies)

What changed

The runtime publish gate judged a stored sibling's finding against a baseline that had dropped the written object's stored self. A label-only save of a master was refused (422) for a stored detail the author never touched. The gate now also judges the stored universe on an update into a context collection.

packages/lint/src/runtime-gate.ts:

  • buildRuntimeWriteSnapshotSet (module-level, on neither package entry) builds the baseline and candidate as before. On an UPDATE into a context collection it also builds stored: the baseline with the written item's stored self put back, at the slot the item takes in the candidate. Every sibling sits at the same index in all three snapshots.
  • runRuntimeAuthoringRules subtracts baseline findings, as before. It also subtracts stored findings, but only those whose path positively names another entry (isLocatedOnAnotherEntry). Findings located on the written item are never read from that pass, so they are judged as before.
  • isLocatedOnAnotherEntry reads a location off the finding's path spelling, never off its rule. It reads the three spellings the door's rules use: positional objects[3]…, name-keyed objects.acme_invoice…, and an object named in prose (object 'fx_detail' · …, the path the expression and autonumber rules emit). A path it cannot locate keeps the previous verdict. That direction can leave a sibling's finding charged to a write; it can never wave the written item's own finding through.
  • buildRuntimeWriteSnapshots is on both package entries, so its signature is byte-identical to main. It returns the baseline/candidate pair read off the new builder. A pin asserts it still returns exactly those two keys.
  • A create, and a write of a type that is not a context collection, run exactly the two passes they ran before.

.changeset/22118-gate-baseline-stored-self.md: patch, @objectstack/lint, with Clause-②: no.

Readings (Zone 2)

All readings are taken at the door's own snapshot shape on this branch, unless they say otherwise.

  • H1: holds. On main 51290bca, the gate charged a label-only fx_master save with object-field-ref-unknown @ objects.fx_detail2.fields.m.lookupColumns[0] and with expression-invalid @ object 'fx_detail' · field 'qty' readonlyWhen. The baseline printed as [fx_account, fx_detail2], with the master absent. The fingerprint is rule · where · path · message. Sibling slots are identical across the passes. What differs is presence: lookupColumns against an absent target is unknowable (validate-object-field-refs, [finding] a misspelt field name in a field's relatedListColumns, lookupColumns, lookupFilters[].field or dependsOn passes every authoring door, and fails only at view or picker time #20432), and the parent traversal cannot resolve. So the finding is new against a baseline without the master.
  • H2: holds. On a create there is no stored self and no stored snapshot, so the verdict is unchanged. It is pinned: creating the master beside the stored detail is still charged with the detail's finding, because the stored universe never held it.
  • H3: holds. Two writes that newly break a sibling are still refused, one per spelling:
    • Positional: the write removes code, which the detail's lookupColumns names.
    • Prose: the write turns status into a lookup, so the detail's readonlyWhen: "parent.status.name == 'x'" now reads through a reference.
    • At the door, the positional control answers { code: 'INVALID_METADATA', status: 422 }.
  • H4, by type:
    • permission: the same defect, at advisory tier. security-master-detail-ungranted is silent while no permission set is authored. A label-only re-save of a tenant's only set was therefore charged a stored detail's warning: objects.fx_line.fields.hdr, measured red under the reversal below. With this change it carries none; creating the same set still reports it. Covered, because the construction is the one shared line.
    • book and dataset: no instance. The book door runs validateSecurityPosture and validateSecurityRoleWord. The dataset door runs validateDatasetMeasureAggregates and validateReferenceIntegrity. Each judges the written entry against permissions or objects and resolves nothing into a sibling of its own collection, so the stored pass cancels nothing there today. See the Acceptance notes.

The contract sentence (narrowed to what holds)

The module header now states what "added" means. The bare sentence "the gate blocks new writes, never stored rows" used to sit in the builder docblock. It now heads a precise list, every item of which the code does:

  • a finding located on another entry is the write's only when neither the universe without the item nor the stored universe holds it;
  • a finding located on the written item itself is the write's whenever the universe without the item does not hold it, whatever the stored row held ("re-saving a row is writing it");
  • a finding whose path names no locatable entry is judged as one on the written item.

Other changed lines:

  • "runs the rules TWICE" became "on the context alone and again with the item grafted in".
  • The cost line now says "two passes … three on an update into a context collection".
  • The restoredCredentialPaths comment states that the stored pass can match the item's slot, and why that is inert.

The reference-integrity-suite.ts sentence ("a stored object already in violation is never charged to someone else's write") sits in a paragraph about the FLOW snapshot, where it was and remains true. It is untouched.

Tests

  • packages/lint/src/runtime-gate.stored-self-baseline.test.ts (new; it keeps off runtime-gate.object-writes.test.ts, which PR feat(spec)!: refuse bare unique: true on a declared index at protocol 18 — stated scope, zero-drift conversion (ADR-0120 D2/D5a/D7) #22103 edits): 25 cases.
    • The two measured cases resolve. Each has a non-vacuity check: the finding is absent from the baseline and present in the candidate and in stored, at the same raw path.
    • Both H3 controls.
    • The written-object control in all three spellings. Each check confirms the stored self carries the identical finding.
    • Create, and permission relabel/create.
    • Snapshot shape, and that the published builder returns only baseline and candidate.
    • Twelve location-reader cases, including five unlocatable spellings, each answering false.
  • packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts, new block #22118: 4 cases through the real saveMetaItem, with the registry holding the stored universe.
    • Both measured cases save, and the row lands.
    • The sibling-breaking write and the written object's own finding are each refused with { code: 'INVALID_METADATA', status: 422 } and the issue's path, and nothing lands.

Reverse verification (one-off, at 8d2a3c3d, no permanent ablation file)

Both legs went through scripts/ablation-replace.mjs, with a literal anchor that must hit (x1 to x0, blob 625a165b to the mutated blob). Each leg ran pnpm --filter @objectstack/lint build and scripts/ablation-dist-preflight.mjs before measuring: the marker was hit in dist/index.{js,cjs} and dist/runtime.{js,cjs}. The door suite reads @objectstack/lint through dist/.

  • Leg 1, the reversal (stored pass disabled, which is main's behaviour):
    • Lint: 3 failed / 22 passed. The failures are the two measured cases and the permission relabel.
    • Door: 2 failed / 2 passed. Both cases answered the card's own refusals: object/fx_master failed author-time validation: 1 issue — objects.fx_detail2.fields.m.lookupColumns[0] [object-field-ref-unknown] and … object 'fx_detail' · field 'qty' readonlyWhen [expression-invalid].
    • Both controls stayed green.
  • Leg 2, the location reader ablated (every stored-pass finding cancels):
    • Lint: 3 failed / 22 passed. The failures are the written-object control in all three spellings.
    • Door: 1 failed / 3 passed. The failure is the written-object control.
  • Restore, after each leg: blob equal to HEAD, git diff HEAD empty, dist/ rebuilt with the marker absent from all 14 built files, and the tree clean.

Local verification at 8d2a3c3d

  • pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2: 124 files, 5705 tests passed.
  • pnpm --filter @objectstack/lint typecheck (tsc --noEmit plus the test layer): OK. No new test-typecheck signature.
  • pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2: 221 files passed, 3 skipped; 28243 tests passed, 19 skipped.
  • pnpm --filter @objectstack/metadata-protocol typecheck: exit 0. --listFiles includes the edited test file (1 hit; 224 test files in the program).
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 63 commands from the merge base. Reconciled with --ran: 63 derived, 62 run green, 1 NOT MEASURED.
    • check-plugin-teardown-shape --self-test and check:lean-entry-closure first answered PREREQUISITE NOT MET: a fixture commit was outside the shallow clone, and objectql had no dist/. Both were re-measured green after fetching the commit and building objectql.
    • NOT MEASURED: check:dual-build-cjs-loads, reason: it reads the built output of every workspace package (about 68 had no dist/ here). That is CI's run. A narrowed direct reading: packages/lint/dist/runtime.cjs and index.cjs load, runtime.cjs exports the same six names, and neither entry exposes buildRuntimeWriteSnapshotSet or isLocatedOnAnotherEntry.
  • Lint, narrowed to the 3 touched TS files with eslint --no-inline-config --format json: 3 files, 0 errors, 0 warnings. eslint.config.mjs never enables type-aware linting (0 hits for parserOptions.project or projectService, and the config says so in prose). So this diff cannot move the verdict for any untouched file. The full pnpm lint run is CI's.

Acceptance notes

  • book / dataset (H4): they share the construction because it is one line. A per-type exception would be a second policy beside the one differential, and the next rule that judged a sibling book or dataset against the written one would re-create this defect silently. On those updates the stored pass costs one more rule pass, with no measured effect today.
  • Unlocatable spellings keep the previous verdict. No door rule emits a double-quoted object "x" path today (the double-quoted form appears only in where beside a positional path). A rule that did would not get this relief until the reader learns that spelling.
  • Observation, not filed: in this branch's probe, a detail's readonlyWhen: "parent.status == 'x'" drew no door finding when the master lacked status. Whether any surface judges field existence through parent was not measured. Carrier: none.
  • Overlap: PR feat(spec)!: refuse bare unique: true on a declared index at protocol 18 — stated scope, zero-drift conversion (ADR-0120 D2/D5a/D7) #22103 edits runtime-gate.object-writes.test.ts. This PR does not touch that file.

Generated by Claude Code

claude added 5 commits October 8, 2026 00:52
… item's stored self

On an update into a context collection the gate now also judges the stored
universe (the baseline with the written item's stored self at the slot the
item takes in the candidate). A finding located on another entry that the
stored universe already holds is no longer charged to the write; findings on
the written item itself, and findings whose path names no locatable entry,
are judged against the baseline alone, as before.

Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK
Co-authored-by: Claude <noreply@anthropic.com>
…ontrols, create and permission

Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK
Co-authored-by: Claude <noreply@anthropic.com>
…stored universe is the gate's own

`buildRuntimeWriteSnapshots` is on both package entries, so its return type
stays the baseline/candidate pair. The construction moves to the
module-level `buildRuntimeWriteSnapshotSet`, which the gate and the pins read.

Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK
Co-authored-by: Claude <noreply@anthropic.com>
Conflict in packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts
only: #22118's stored-self block and #22042's nested-predicate block were both
appended after the pass-2 block. Both are kept whole, #22118's first.

Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK
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 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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 8fc50b7647d30db2a0837877cf251c1163a3239e → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 8fc50b7647d30db2a0837877cf251c1163a3239e

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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 03:03
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 8, 2026 03:03
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 6c17a50 Oct 8, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22118-gate-baseline-stored-self branch October 8, 2026 03:37
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
… verdict (objectstack-ai#22032 pass 3) (objectstack-ai#22151)

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

This is pass 3 of objectstack-ai#22032: a field option's `visibleWhen`. Pass 4 (the
object's own action predicates) stays fenced, and the card stays open
for it.

## What changes

**The object save door gives the build's verdict on a field option's
`visibleWhen`.** `formulas.mdx` says "the same `validateExpression`
validator backs `os build` and metadata registration". After pass 2 the
object door ran the whole field walk except the per-option loop, which
kept its own guard. So an object whose option carried `visibleWhen:
'amount > 1'` (a bare field reference) still saved with a 200, while `os
build` refused it at error. The server's option check cannot evaluate
such a predicate and fails open (logged, allowed through), so the gate
it declares is never enforced (read from `evaluateOptionVisibility` in
`packages/objectql/src/validation/rule-validator.ts`).

- **The lift is the guard, nothing else (H1 held).** On `origin/main`
`8fc50b7647` the loop read `for (const [oi, opt] of (objectWrite ? [] :
recordsOf(f.options)).entries())` (`validate-expressions.ts:2072`),
under a `[objectstack-ai#22032] FENCED on an object write (pass 3 …)` comment. It now
reads `recordsOf(f.options)`. On an object write the loop runs at the
build's own position in the field walk, so the door gets both of the
option's checks:
  - `check(optionWhere, opt.visibleWhen, objectName, 'record')`;
- `refuseFieldTraversal(optionWhere, 'option visibleWhen', …)`, the
refusal of a read through a reference field on `record` or `previous`.
- **`current_user` keeps the build's two verdicts (H2).** An option's
evaluator binds the acting user (ADR-0068 D1), so the build accepts
`current_user` on an option and refuses it on the field-rule slots one
level up. The door now gives both verdicts as the build does. The
showcase's role gate, `'org_admin' in current_user.positions`, still
saves on an option, and the same text on the field's own `visibleWhen`
is refused at both doors. Both are pinned.
- **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.** `StackExpressionOptions.runtimeWriteType` now
names four admitted passes and one fenced pass.
`AuthoringRuleContext.runtimeWriteType` in `authoring-rules.ts`, the one
line that reaches a built `.d.ts`, names the per-option pass. The
function-head comment and the field walk's two comments move with it. In
`authoring-rules.ts` the registry entry gains a `[objectstack-ai#22032, pass 3]`
measurement comment, as passes 1 and 2 added theirs. Comments in four
sibling test files are corrected so that none of them still says option
`visibleWhen` is fenced.
- **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_option' · field 'province'
option 'zj' visibleWhen`), 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-option-visibility-writes.test.ts`
(new, 14 tests).
- LIT: one refused body per finding the pass gives. These are a bare
`amount > 1`, an unregistered `sqrt(record.amount) > 1`, an unknown
field `record.amont > 1`, a syntax error `record.country ==`, and the
traversal refusal through `record.account` and through
`previous.account`. Each is located at the option and asserted on its
named subject.
- CONTROL (the still-accepted cases): the `record.country` cascade, the
showcase's `current_user.positions` role gate, a grant check plus role
gate (`current_user.can(…) && …`), and a reference compared as a value
(`record.account != null`). Each is clean at the door and at the build.
- CONTRAST: `current_user` on the option and on the field's own
`visibleWhen` in one body. The build and the door both give exactly one
finding, at the field slot.
- PARITY: for each refused body, the door's findings equal the build's.
- The differential: a stored sibling's broken options 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 site is now pass 4's alone (an action `visible`). The option
`visibleWhen` site moves to the lifted sites, beside the validation rule
and the `requiredWhen`. The build flags all four sites. The object door
flags the three 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-3 block in
`packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`,
through the real `saveMetaItem`, `publishMetaItem` and
`publishPackageDrafts`.
- (a) A bare reference, an unregistered function and a read through a
reference field are each refused on an active save. The answer is a 422
`INVALID_METADATA` carrying the build's located finding, and nothing
lands.
- (a) The card-shaped 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) The showcase's cascade and its `current_user` role gate still
save, and the row lands active. The role gate rides every refused body
too, which each yield exactly one finding.
- (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 `23ce6c494c`)

- **What was mutated.** `scripts/ablation-replace.mjs` (wrap mode) put
the guard back on the option loop. The new text was `const
ablationFence22032p3 = objectWrite;` followed by `for (const [oi, opt]
of (ablationFence22032p3 ? [] : recordsOf(f.options)).entries()) {`. The
anchor was hit once, 1 to 0, and the blob went `879fcb828037` to
`73bd026d1554`. The outer script carried `trap restore EXIT INT TERM` on
the absolute path.
- **Rebuild and dist proof.** `@objectstack/lint` was rebuilt, and
`ablation-dist-preflight` found the marker in 4 built files.
- **Lint suites (source): 9 failed and 14 passed, as predicted.**
- Red: the 6 LIT tests, PARITY, and the two fence tests that assert the
lifted sites.
- Green: the 4 CONTROL tests, CONTRAST, the differential, the registry
test, the build-flags-each-site test and the six formula-door tests.
- **Protocol pass-3 block (dist-mediated): 6 failed and 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 `879fcb828037` equals
HEAD, and `git diff HEAD` is empty. The whole tree had 0 changed paths.
Lint was rebuilt, and `--absent` found the marker gone from all 14 built
files. Both suites went green again: lint 23 of 23, and the protocol
file 99 of 99.

## Measurements

- **Corpus first: the stop condition was not met (H5).** Every object
this tree ships was judged before the door changed. That is every
`*.object.ts` under `packages/**` and `examples/**` (111 files) plus the
two `app-multi-package` sub-stacks: 118 objects in 18 groups. Each was
judged at the raw shape and at the `ObjectSchema.parse` shape (0 parse
failures), with its own group as context.
- 5 option predicates on 2 fields of 1 object, all on
`showcase_cascade`: `province`'s four `record.country` cascades (`zj`,
`gd`, `ca`, `tx`) and `tier`'s `restricted` role gate (`'org_admin' in
current_user.positions`).
- At base `8fc50b7647`: 0 build errors and 0 build warnings for the
option pass, through `validateStackExpressions` and through
`runAuthoringRules('build')`, at both shapes. There were 0 door
expression findings over all 118 objects.
- Non-vacuity: the 5 sites are judged. Mutating one cascade to a bare
`country` and the role gate to `sqrt(record.amount) > 1`, in a copy,
gave 2 build errors at those two options.
- At head `23ce6c494c`, and again at the merged heads `06d3cad963` and
`3c1d3262ee`: 0 door errors and 0 door advisories over every object,
through `runRuntimeAuthoringRules` with type `object`, at both shapes.
The harness passes each object's own group as context, so at
`3c1d3262ee` every one of those saves is an update and also runs the
stored-universe pass that objectstack-ai#22118 added.
- Positive control in the same harness (an option `visibleWhen: 'amount
> 1'`): 1 build error at every head. The door gave 0 at base and 1 at
head.
- **Which doors newly answer 422.** The active publish save, a draft's
promotion, and a package draft publish. Each was measured through the
real methods above. A draft save stays ungated, measured by the same
pins.

## Clause-② (measured)

- **Accept set: narrowing.** An object write in publish mode answered
200 for an option `visibleWhen` 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-option-visible-when.md` covers
`@objectstack/lint` and `@objectstack/metadata-protocol` as `minor`. It
carries `fix(lint)!`, the `Clause-②: no (narrowing)` line, a BREAKING
section with the remedy, and ADR-0087 `not-required
(no-migration-prescription)`. `@objectstack/metadata-protocol` is listed
as passes 1 and 2 listed it, although no code moves there: its save,
promotion and package-publish doors are where the BREAKING behaviour can
be seen. `.changeset/pre.json` is absent on `origin/main` (read at
`8fc50b7647`, 2026-10-08T01:54Z, at `ef1fcb26a2`, 2026-10-08T02:40Z, and
at `7ef50a4fbb`, 2026-10-08T03:39Z), so the bump is `minor` with the
BREAKING banner, as in passes 1 and 2. The changeset says it supersedes
the earlier objectstack-ai#22032 entries' line that option `visibleWhen` is not judged
at this door.

## Merges

- **`06d3cad963`** merges `origin/main` `ef1fcb26a2` (PR objectstack-ai#22103),
through `scripts/pm/os-regen-merge.sh`. It was clean.
- **`3c1d3262ee`** merges `origin/main` `7ef50a4fbb` (parents
`06d3cad963` and `7ef50a4fbb`), through `scripts/pm/os-regen-merge.sh`.
That brought PR objectstack-ai#22129 (objectstack-ai#21982), PR objectstack-ai#22094, PR objectstack-ai#22134, PR objectstack-ai#22133
(objectstack-ai#22118, the gate's object-write baseline keeps the written item's
stored self), PR objectstack-ai#22126 and PR objectstack-ai#22140.
- `validate-expressions.ts` auto-merged. PR objectstack-ai#22129's edit is in the flow
`script` / `subflow` region; the option loop's lift is unchanged.
- One conflict:
`packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`,
one hunk. Both sides had added a new top-level `describe` block at the
same place, after the pass-2 block. It was resolved by stacking: the
pass-3 block first, then objectstack-ai#22118's stored-self block, and every line of
both was kept. Against `7ef50a4fbb` the file shows only this branch's
lines; against `06d3cad963` it shows only objectstack-ai#22118's 114 lines.
- No generated path was touched by this branch. The rerun of the script
took `origin/main`'s side of every generated path main moved, and that
left nothing to commit.
- This branch's own changes are the same before and after the merge: for
each of the 9 files, the added and removed lines against the merge base
are identical. The delta against `7ef50a4fbb` is 9 files, +467 / −45.
- **The interaction with objectstack-ai#22118, measured.** objectstack-ai#22118 adds a third,
stored-universe pass on an update into a context collection. A finding
whose path names no entry is judged as one on the written item. The
expression rule's paths are `where` strings, so an option finding is
always judged that way.
- A probe ran the real gate (`runRuntimeAuthoringRules`, type `object`)
over 20 cases, against this branch's lint source before the merge
(`06d3cad963`) and after it (`3c1d3262ee`). The cases are a create, an
update over a stored self that is broken and over one that is clean, and
a stored sibling that is broken, each for three refused bodies, plus two
still-accepted bodies.
- The verdicts are identical, case for case. Re-saving a broken option
is still refused, including over a broken stored self, because re-saving
a row is writing it. A clean save over a broken stored self passes. A
broken sibling is never charged.
- The pass-3 pins (differential, PARITY, LIT, CONTROL, CONTRAST) and
objectstack-ai#22118's pins all pass at `3c1d3262ee`: lint 48 of 48 across the three
files, and the protocol file 103 of 103.
- This matches the code. The option pass reads only the written object's
own field index, and binds `record` and `previous`, never `parent`, so
the stored universe has nothing extra to offer it.

## Tests and gates (all at `3c1d3262ee`, the merge of `origin/main`
`7ef50a4fbb`)

- **`main` moved during the run (H6).** PR objectstack-ai#22103 landed as `ef1fcb26a2`
and touched two files this pass edits, `authoring-rules.ts` and
`runtime-gate.object-writes.test.ts`, in other hunks. It was merged with
`scripts/pm/os-regen-merge.sh` as a merge commit. The merge was clean,
and no generated path was taken from either side. After the merge: `pnpm
install --frozen-lockfile`, a full turbo build (72 tasks), and
`@objectstack/spec check:generated` ("All 15 generated artifacts are up
to date"). objectstack-ai#22118 and objectstack-ai#21982 had not landed at that read
(2026-10-08T02:40Z). Both have since landed, and they are merged at
`3c1d3262ee` (see Merges). After that merge the same sequence ran: a
full turbo build (72 tasks) and `check:generated` ("All 15 generated
artifacts are up to date").
- **`@objectstack/lint`:** 125 files and 5729 tests passed. `typecheck`
exit 0, with its test-typecheck included (`--listFiles`: the five
touched or new lint test files are in the `tsconfig.test.json` program).
- **`@objectstack/metadata-protocol`:** 221 files passed and 3 skipped;
28260 tests passed and 19 skipped. `typecheck` exit 0 (`--listFiles`:
the door test file is in the program).
- **Consumer readings.** Every package was built at the head named.
- `@objectstack/objectql`, 8 files and 282 tests passed:
`publish-package-drafts-response-conformance`,
`save-meta-response-conformance`, `publish-meta-response-conformance`,
`plugin.integration`, `engine-field-predicate-fault`,
`engine-option-permission-predicate`,
`validation/rule-validator.option-visibility` and `engine`.
- `@objectstack/rest`, 11 files and 197 tests passed: every
`meta-object-*` file and `meta-publish-package-scope`.
- `@objectstack/cli`, 3 files and 16 tests passed, run as `--project
integration` because the tier predicate puts them there:
`validate-field-predicate-traversal` (which asserts an option
`visibleWhen` `expression-invalid` finding),
`authoring-rule-command-parity` and `verify-author-time-stage`. The
three nightly-tier `*.e2e` files that assert `expression-invalid` are
left to CI.
- The search for other consumers covered every test file in the
repository carrying `visibleWhen`, matched against the save-door entry
points. Only the two packages above save an option `visibleWhen` through
a door.
- **Gates.** `dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 63 commands, the same set at
`23ce6c494c`, `06d3cad963` and `3c1d3262ee`. At `3c1d3262ee` all 63 exit
0, each exit code captured before any pipe. `--ran` reconciles 63
derived, 63 run, 0 NOT-MEASURED and 0 UNRUN, with an exit code recorded
for each. The printed artifact-roster block names 51 families, and all
51 ran at `3c1d3262ee`, each with exit 0. That is 47 in the same
battery, one (`check:engine-double-contract`) already among the 63, and
the three PR-scoped ones (`check-partof-closing-keyword`,
`check-closing-target-claim`, `check-single-claim-paths`) run with this
PR's number and this body. The same 63 also ran at `06d3cad963`, all
exit 0. At `23ce6c494c`, before the merge, the same 63 ran: 61 exited 0
on the first run. `check:dual-build-cjs-loads` and
`check:lean-entry-closure` first exited 3 (PREREQUISITE NOT MET: no full
build) and exited 0 after the full build.
- **ESLint, narrowed to the 8 touched TypeScript files**
(`--no-inline-config --format json`): 8 files, 0 errors and 0 warnings.
Each file is matched by `eslint.config.mjs` (`--print-config`), and none
was ignored. The config enables no type-aware linting (no
`parserOptions.project`), so this diff cannot move the verdict on any
untouched file.

## Acceptance notes

- **Out of this pass, reported for the seat: an option `visibleWhen`
reading `parent` gets no verdict at the build, so none at the door
either.**
- Measured at `23ce6c494c` with scratch tests that were deleted
afterwards. Through the real `saveMetaItem`, an object whose option
carries `visibleWhen: "parent.status == 'closed'"` saved with success,
and the row landed `active`. `runAuthoringRules('build')` gave 0
findings.
- The server's option check (`evaluateValidationRules`, authenticated
caller, the option picked) then logged `failed to evaluate
(authenticated caller) — allowed through`, with `Unknown variable:
parent`, and admitted the value. The option evaluator binds `record`,
`previous`, the user and permissions only.
- This is a gap in the build, which the door now mirrors. This card's
contract is parity with the build, so it is not changed here.
- **A code comment names an old spelling.** The comment above the option
loop calls the showcase's legal usage `'admin' in
current_user.positions`. The showcase now writes `'org_admin' in
current_user.positions`, and the pins use that spelling. The comment is
not changed here: per the contract review, it rides pass 4.
- **The pending objectstack-ai#22032 changesets.** Pass 1's and pass 2's changesets
each list option `visibleWhen` under "Unchanged", which was true at
their heads. This pass's changeset says it supersedes that line rather
than editing them, the same way pass 2 left pass 1's changeset alone.
Per the contract review, reconciling those lines rides pass 4.
- `formulas.mdx` could name the object save door. That would be a docs
addition, not a correction of a false line.
- **Contract review.** Triage's grade asks for one per pass. A PASS is
on record for head `06d3cad963` (`6051573476`). The head has since moved
to `3c1d3262ee` by the merge above, so the review for this head is the
seat's.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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

2 participants