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.

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