Skip to content

fix(spec): a truncated-history build judges a commit by the anchor it records, never by origin/main - #24436

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-24359-schema-ratchet-commit-anchor
Oct 19, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-24359-schema-ratchet-commit-anchor

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24359
Clause-②: no

What changed

packages/spec/scripts/build-schemas.ts computes ONE baseline for its three git-reading ratchets: the schema removal check, the authorable-key deletion check and the default-change check. Where merge-base HEAD origin/main answers, nothing changes: that is the path this repository's own PR and merge-queue gate runs (Type Check · source gates checks out with fetch-depth: 0).

Where history is truncated, which is every --depth=1 rebuild of a pinned commit, the old resolver picked its anchor in this order:

  1. the tree's own authorable-surface.base.json baseRev, but only if origin/main vouched for it;
  2. otherwise the rev origin/main's anchor names;
  3. otherwise origin/main's tip.

Arms 2 and 3 make the verdict about commit X a function of where main points when the build runs.

Now a truncated history always anchors on the rev the tree under test records. Its keys, manifest and defaults are still read out of git at that rev (never out of the file), and the file is still checked against them. With no anchor the tree can read, the run refuses: either the file is missing, or its baseRev cannot be fetched. It never falls back to the tip. --update-base is the one mode that still reads the tip in a truncated history, unchanged; moving an existing anchor still refuses there (the forward-only rule).

Per ratchet in the file (measured, not assumed):

  • the manifest removal check, the authorable-key deletion check and the default-change check all read the single resolveSurfaceBase() resolution, so all three share the defect and all three get this change at once;
  • the never-published ratchet (lib/unemitted-schemas) and the dropped-refinement ratchet read only in-tree baselines (no git reads), so neither shares the defect and neither changes.

Measured: the release commit, rebuilt the way objectui rebuilds it

The commit @objectstack/spec@18.0.0-next.3 was released from, 2c9bf9430a, rebuilt with pnpm gen:schema in an objectui-shaped clone: git init, a remote, a depth-1 fetch of the pin. Its build-schemas.ts is byte-identical to main's, so "after" means that commit with this script copied in. Every leg's anchor rev is 85c6d76ec42a unless stated otherwise.

remote the clone fetches main from before (script a266dcd7e296) after (script 48aab23a9e72)
real GitHub main (71fa166e96 before, e22defb13c after) exit 0, anchored on 85c6d76ec42a (main still names the same anchor) exit 0, anchored on 85c6d76ec42a
a main whose anchor commit cannot be fetched (models the objectui CI jobs; see notes) exit 1: anchors on the TIP; 3 schema(s) left the published set with no registered removal: api/PackageInstallManifest, security/DefaultRowScope, ui/PageRecordBinding, all added on main after the release exit 1, an accurate refusal: the anchor commit was not served, so the run refuses rather than judge the tree against main or skip the checks
a main that re-anchored past the release (synthetic commit 07857fe78f on the tip, as --update-base writes it) exit 1: anchors on main's anchor rev 71fa166e965a; the same 3 schemas exit 0, anchored on 85c6d76ec42a
no remote (objectui's workaround) exit 0, offline in-tree anchor exit 0, offline in-tree anchor

So for every origin/main (the real tip, a re-anchored main, none) the release commit gets the same verdict on the same anchor. The only red after the change is a remote that will not serve the commit's own anchor, and that red says exactly that.

objectui's real failure (PR objectui#12963, the Type Check job's annotation) matches the before/fetch-failure leg message for message. First "no upstream anchor was usable either … anchors on the origin/main TIP 51ad8e4fba87", then ❌ 2 schema(s) left the published set …. At that tip main's own anchor also named 85c6d76ec42a, so the old resolver's arm 1 was lost only because the anchor fetch failed in that job.

Ruling ③: the ratchet does not weaken where it guards

The ratchet's own refusal pins (the a deleted manifest key must prove itself block): the bypass repro is red on a removal, a declared removal is green, and the negative control is green.

  • before, at BASE 71fa166e96: Tests 3 passed
  • after, at HEAD a99d2041d4: the whole block, 9 passed, inside a 28/28 run

Those cases run on walkable history, the PR gate's path, which this change does not touch.

New in the shallow block, a commit that is already on main, rebuilt in a truncated clone:

  • same verdict: under a main that only moved on and under a main that re-anchored past it, both runs are green on the commit's own anchor, and the removal comparison really ran;
  • teeth: a schema the commit's anchor recorded and the build dropped, unregistered, is red under either main, naming that schema. Under the re-anchored main the old resolver missed this removal and named one of main's additions instead.

The trade, stated

A truncated history cannot prove baseRev is upstream, so a branch that points baseRev at its own commit is no longer overridden with main's anchor in a shallow job. That forged rev is refused where history is walkable: NOT an ancestor of origin/main, the existing authenticity check, which runs on this repository's PR and merge-queue gate. Both halves are pinned in one case: the shallow run uses the tree's rev, and the walkable run refuses it.

Tests (all at HEAD a99d2041d4)

  • build-schemas-check-mode.test.ts (vitest repo project; the file is about 20 minutes long, so it ran in four -t slices): 92 of 92 passed (13 + 28 + 29 + 22).
  • def-key-collisions.test.ts, published-projection-choke-point.test.ts and schema-tree-freshness.test.ts (they spawn, read or digest this script): 35/35 passed.
  • @objectstack/spec local project: 696 files, 21251 tests passed.
  • pnpm --filter @objectstack/spec typecheck: exit 0. tsc -p tsconfig.scripts.json --listFiles includes both changed files.
  • Ablation, one-shot. BASE's build-schemas.ts was put on disk (hash a266dcd7e296… matched BASE's blob), the shallow block ran, then the file was restored (hash 48aab23a9e72… matched HEAD's blob, git diff HEAD empty). Result: 7 failed, 2 passed. The 2 are the unchanged cases (a real deletion stays red; keys come from git, not the file). The two new cases failed on the old resolver exactly as described above, with 1 schema(s) left the published set … ui/ZzzPublishedOnMainAfterTheCommit. No dist/ leg: the test copies scripts/ and runs it with tsx.
  • One fixture in the drift-notice block was re-based without changing its subject. Its shallow state now keeps merge-base answerable, because a shallow run with no merge base now anchors on exactly the committed anchor and has no drift left to describe.

Gates (local, derived with dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at a99d2041d4)

--ran reconciliation: 65 derived, 60 run, 5 NOT-MEASURED, 0 UNRUN (exit 0).

  • 60 ran, every one exit 0. This includes the five spec gates (check:authorable-surface, check:empty-state, check:liveness, check:strictness-ledger, check:variant-docs), check:nul-bytes, check:published-files, check:changeset-gate-self-tests, check:adr-0087-registration --base origin/main, and check:pm-dispatch-gates. That battery ran detached; its verdict line was 1913 cases pass (fast tier).
  • NOT MEASURED, check:dts-closure, check:dual-build-cjs-loads, check:lean-entry-closure, check:sourcemap-no-sources-content: exit 3, prerequisite not met (no workspace dist/ in this worktree).
  • NOT MEASURED, check:type-check-debt: it re-measures tsc for every package under a closure build and exceeded a 420 s bound (exit 124).

CI runs all five NOT MEASURED families.

Changeset: skip-changeset

packages/spec/scripts/** is outside files[]. npm pack --dry-run lists 0 scripts/ entries.

Measured: none of this diff's symbols or strings appears in any files[] path, nor in dist/ (each 0 files). Positive controls do hit: x-unprojectable-branches, which this script writes into json-schema/, is in 6 files; PageRecordBinding is in 22 dist/ files.

The one shipped byte sequence that moves is json-schema/.build-input-hash-schema, a freshness digest whose inputs include scripts/**, as they do for any test-only spec change. The precedent is 0d43004100, which changed only this script and carried no changeset.

Acceptance notes

  • Applies to commits that carry it. A rebuild runs the script of the commit being built. The 18.0.0-next.3 commit keeps its own resolver forever, so objectui's remote-drop workaround stays needed until its pin moves to a commit carrying this change. Retiring the workaround is objectui's.
  • Boundary, unchanged: a full clone whose origin/main is OLDER than the commit's own baseRev still refuses with NOT an ancestor of origin/main. Stale origin/main and a forged anchor are indistinguishable without history, and that refusal is the guard. Noted, not filed; the carrier is none.
  • Inferred, not measured: why the anchor fetch failed in 13 of objectui's 25 jobs. The annotations show the fetch failed in the re-anchor call and succeeded moments later in the authenticity check. The job logs are on a blob host this container cannot reach (403).
  • The origin/main the tree was compared against moved 2 commits after BASE, neither touching packages/spec/; the branch was not merged with main.

Generated by Claude Code

…own recorded anchor, never on origin/main

Claude-Session: https://claude.ai/code/session_01WnW7HvfnfHbLg1fpkHX3Mu
Co-authored-by: Claude <noreply@anthropic.com>
… main that moved on or re-anchored, refusal with no readable anchor

Claude-Session: https://claude.ai/code/session_01WnW7HvfnfHbLg1fpkHX3Mu
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Oct 19, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see

Coarse fallback — 0 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 db1f1fd20fba543408b209746e566b48373a4622 → packageMentionDocs.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:spec seat 1 · os-warren · session session_01WnW7HvfnfHbLg1fpkHX3Mu · 2026-10-19T03:22Z · head a99d2041d4 · card #24359 (claim 6257468098).

  • Form: draft, base main, first line Fixes #24359, and no other closing keyword in the body. Clause-②: no at line start, matching the claim. Assignee os-warren. Labels skip-changeset, size/l, tests, tooling.
  • Path face (check-governed-merges.mjs --pr 24436): NOT governed, 0 of 2 paths; 537 changed lines, under 3000. Machine face only: packages/spec/scripts/build-schemas.ts and packages/spec/scripts/build-schemas-check-mode.test.ts, both inside the claim's surface. No packages/spec/src/**, so no contract review is owed.
  • Premise measured: the defect is the truncated-history arm, not a full clone. Read in the diff: where merge-base HEAD origin/main answers, rev is still the merge base, untouched. The shallow arm now takes the tree's own baseRev (resolveBaselineWithoutMergeBase), and with no readable anchor it exits 1 with a named refusal instead of anchoring on the tip. readUpstreamAnchorRev is deleted. --update-base alone keeps the tip, as before.
  • Ruling ③ (the ratchet does not weaken where it guards): the forged-anchor case that a shallow job no longer overrides is refused on walkable history by verifyCommittedSurfaceBase ("NOT an ancestor of origin/main"). That job is typecheck-source-gates ("Type Check · source gates"), which checks out with fetch-depth: 0 and is one of the four needs of the required aggregate TypeScript Type Check (lint.yml on origin/main). So the queue still refuses a forged anchor. The refusal pins are cited before and after (BASE 3 passed; HEAD 9/9), with a one-shot ablation (7 failed / 2 passed on BASE's script, restored by blob hash).
  • Changeset: skip-changeset holds: packages/spec's files[] (dist, json-schema, liveness, prompts, src/**/*.zod.ts, …) carries no scripts/ path. Read on origin/main 538f923cb9.
  • Gates: 45 check-runs on a99d2041d4: 35 success, 10 skipped, 0 other (read 2026-10-19T03:22Z). The 5 families the dev could not run locally run in CI and are green there.
  • Accepted deviations: the 20-minute test file ran in four -t slices covering all 92 cases; one drift-notice fixture was re-based without changing its subject; the branch was not re-merged with main before opening (2 later commits, neither in packages/spec/).
  • Out of scope: two notes, neither filed. ① A full clone whose origin/main is older than the commit's baseRev still refuses; that is the forgery guard, and nobody carries it. ② Why objectui's anchor fetch failed is not measured. Both are in the PR's Acceptance notes. Retiring objectui's remote-drop workaround (objectui#12963) is objectui's, once its pin moves past this change.
  • Landing: ready and auto-merge in the same act, through the relay; then MERGED is confirmed on the queue branch and on origin/main, and the card's pm:dispatched comes off.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 19, 2026 03:23
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 19, 2026 03:23
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 19, 2026
Merged via the queue into main with commit d9d41dd Oct 19, 2026
46 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24359-schema-ratchet-commit-anchor branch October 19, 2026 03:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate tests tooling

Projects

None yet

2 participants