Skip to content

fix(spec): re-anchor the authorable-surface deletion gate when merge-base cannot answer (#6452) - #6461

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-6452-surface-base-shallow-anchor
Aug 8, 2026
Merged

fix(spec): re-anchor the authorable-surface deletion gate when merge-base cannot answer (#6452)#6461
os-project-manager merged 2 commits into
mainfrom
claude/issue-6452-surface-base-shallow-anchor

Conversation

@os-project-manager

@os-project-manager os-project-manager commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Fixes #6452

Adopts the seat ruling's third path: move the anchor, not the verdict. When
merge-base HEAD origin/main cannot answer, the authorable-surface deletion gate
(#4650) now anchors on an upstream anchor rev instead of on origin/main's tip —
and the baseline's keys are read out of git at that commit, never out of
authorable-surface.base.json itself.


The defect, restated from the code

resolveSurfaceBase() carried

const mergeBase = git('merge-base', 'HEAD', tip);
const rev = mergeBase.status === 0 ? mergeBase.stdout.trim() : tip;

Under a tip anchor, "main added a key after this branch forked" and "this
branch deleted a key" are the same fact, so the gate reports the first as the
second. #6359 measured it: PR #6356 touched no packages/spec file at all and
was told it had deleted ui/BulkActionDef:requiredPermissions, a key main had
just added.

Verified against origin/main @ f1850d8 before implementing — all of the
issue's blast-radius claims still hold verbatim:

claim state on origin/main @ f1850d8
the fallback lines build-schemas.ts:1526-1527, byte-identical to the quote
not protected by --check the calling block is a bare top-level block, no if (CHECK)
the verdict is unconditionally fatal process.exit(1) in that same block
gen:schema is part of build "build": "pnpm gen:schema && pnpm gen:openapi && tsup …"

A timing note, since it changed mid-task: when this branch was cut at f1850d8
the half the issue describes as already landed ("#6359 的 PR 里 … shallow 那行日志
现在点名方向") was not on mainlint.yml's typecheck job still had no
fetch-depth: 0, and the shallow log line was still the original one-liner. #6459
landed both an hour later, and this branch has since merged it. The
resolution keeps the re-anchor and hands #6359's wording to the one arm it still
describes: the tip fallback, now reached only when no upstream anchor is usable.
The two fixes are complements, not substitutes — #6459 gives one job a walkable
history, this one stops the fallback misjudging in every job that has none.

What this changes

When merge-base fails, the rev comes from an upstream anchor, accepted only
when something the PR does not control vouches for it, in this order:

  1. reachability demonstratedprobeAncestry answers yes, which is proof
    in every checkout (a cut walk can only lose reachability, never invent it);
  2. origin/main names the same rev — main's own committed
    authorable-surface.base.json points at it. This is the ordinary case: the
    anchor moves only under an explicit --update-base (check:authorable-surface--check 模式下也会重写 authorable-surface.base.json —— 一次纯核验会改工作区,且任何无关 PR 都能因此静默推进删除门的锚点 #5358);
  3. otherwise, the rev main names, never the one this tree names.

With none of those available the tip is kept and the run says so, naming
fetch-depth: 0. Keys always come from readSurfaceKeysAtRev at the chosen rev.

--update-base is deliberately excluded from the fallback: its job is to resolve
a new baseline out of git, so anchoring it on the anchor is circular — it would
report "nothing to re-anchor" instead of the #5370 refusal a truncated history
owes it.

Side effect of premise 3 (reuse, don't fork): verifyCommittedSurfaceBase's
open-coded merge-base --is-ancestor read is folded onto the shared
probeAncestry. It had the cloud#1116 trap in it — git declining to answer
(exit 128) was read as a verdict of "not an ancestor" and failed the build; the
shared reading tells the two apart and takes that function's own documented
disposition on unknown (skip, do not accuse).

Why the gate does NOT weaken under shallow

The issue's acceptance asks for this argument explicitly.

  • The check still runs and still adjudicates. Nothing is skipped, waived or
    made conditional. Option (a) — report "unverified" — was refused because it is
    the authorable-surface 的 tombstone 门禁可被手编基线绕过 —— 删掉基线行就删掉了证据(#4638 / #4643 已两次这样过绿) #4650 bypass in every shallow job at once; this PR does not take one step
    toward it.
  • A key that existed at the anchored rev and is gone now is still caught.
    Pinned: the gate does not weaken: a genuine deletion is still red under the same truncation — same tree, same .git/shallow, exit 1, naming the deleted
    key and not main's addition.
  • What leaves the compared set is exactly the false-positive set — keys main
    added after the anchored rev. Those cannot be deletions by this branch: the
    branch never had them.
  • The anchor rev is upstream, or it is not used. See the falsification below:
    this is where the PR goes beyond the ruling's letter, to keep its intent.

Premise checks (measured, one falsified)

P1 — baseRev is a verified ancestor of origin/main: FALSIFIED in exactly the
environment this fix targets, and repaired rather than forced.

verifyCommittedSurfaceBase's authenticity definition has two parts, and part 1
(ancestry) is skipped in a shallow checkout — the function says so itself
("shallow checkout — cannot walk history to confirm … only its recorded keys are
verified here"). Measured in this container:

$ git rev-parse --is-shallow-repository      -> true
$ git fetch --quiet --depth=1 origin 44106d9e84486dc87334d526ed53fdf4595203c9  -> exit 0
$ git merge-base --is-ancestor 44106d9e8448 origin/main                        -> exit 1

The fetched commit is grafted in as its own shallow root, so the ancestry probe
returns a false negative and probeAncestry correctly reports unknown. That
matters because today the skip is free — resolveSurfaceBase's own doc comment
records why: "in that environment the merge-base anchor — not this file — is what
the deletion check ran on anyway."
Making the file load-bearing there removes
that sentence's protection, and a PR can point baseRev at one of its own
commits (a --depth=1 fetch resolves any sha the remote advertises, its own head
included) whose shards already lack the key it is deleting: both halves of the key
check then pass, against a baseline the PR authored.

So the rev is earned, per the ordering above — the decisive rung being that
origin/main's own copy of the anchor is readable in exactly the checkouts where
ancestry is not (a --depth=1 fetch of main carries the tip's whole tree). Pinned
by the case an anchor rev nothing upstream vouches for is not used.

P2 — fetching the single baseRev from a shallow checkout: CONFIRMED (exit 0
above, through this container's proxy). The honest degradation when it cannot be
fetched — an offline container, or a rev nothing upstream advertises — is defined
explicitly: keep the tip anchor and say so, naming fetch-depth: 0. Pinned by the
case with no upstream anchor at all it keeps the tip and says so. A diagnosable
false red beats a silent bypass; the fully-offline route (#5235) is untouched,
since it is reached only when origin/main does not resolve at all.

P3 — reuse #6309's probeAncestry / relateAnchorToBaseline: CONFIRMED and
applied.
probeAncestry covers what this fallback needs (its documented
asymmetry — a positive stands in a truncated history, a negative does not — is
precisely rung 1), and the extraction is now used by one more caller instead of a
third open-coded read. relateAnchorToBaseline needed no change.

#6360's authorable-defaults fingerprint gate: same mechanism, fixed in the same
stroke.
It resolves its baseline through resolvedSurfaceBase.rev, i.e. the
very value this PR re-anchors — as does checkManifestRemovals (#4725), through
gitResolvedAnchor.rev. No separate card is needed; all three anchor consumers
move together, which is what keeps "both halves of a baseline always come from the
SAME anchor" true.

Reverse verification — direction predicted BEFORE running

Two mutants, because the fix has two distinct failure modes:

R1 — restore the tip fallback. Predicted: the "no false red" case goes RED
again. Measured (3 of 5 cases red; the false red reproduced verbatim):

+    (shallow history — using origin/main tip d05c921d8632 as the baseline anchor)
+ ❌ 1 authorable baseline line(s) were deleted without proof (#4650):
+      - data/Object:zzAddedOnMainAfterTheFork6452 — def reachable from the metadata-type
+        roots; the entry at d05c921d8632 was LIVE (never tombstoned).

R2 — take the baseline keys from the anchor FILE (the prohibited shortcut).
Predicted: the self-verification case flips from red to GREEN, because
verifyCommittedSurfaceBase's rev === resolved.rev fast path would then compare
the anchor against itself and the shed line would pass. Measured: exactly one case
fails, on expected +0 to be 1 — i.e. exit 0 where the fix exits 1. That is the
acceptance criterion "the self-verification fast path is structurally unreachable"
asserted rather than assumed: the assertion is behavioural, because when the
anchor is authentic the two key sets are identical and no runtime check can tell
the sources apart — only a tampered anchor can.

Turbo-cache scoping (the issue's flagged-but-untested claim) — measured

turbo run build --filter=@objectstack/spec --dry=json, comparing the task hash:

edit spec build hash
baseline b77913c997fea8a7
touch packages/core/src/index.ts b77913c997fea8a7 (unchanged, cache hit)
touch packages/spec/scripts/build-schemas.ts e7ab39537148d444 (miss)

So the issue's claim is confirmed for turbo-driven build tasks: the task
declares no inputs, so it defaults to the package's 876 tracked files, and a PR
touching nothing under packages/spec gets a cache hit and never executes
gen:schema.

But the scoping conclusion it draws — "实际触发面是「改了 packages/spec 的 PR + 冷缓存的
job」" — understates the surface, and the counter-example is the reported
incident itself.
lint.yml's TypeScript Type Check job runs
pnpm --filter @objectstack/spec check:authorable-surface as a direct script
step, not a turbo task
, so turbo caching gives it no protection at all: it runs
build-schemas.ts --check on every PR regardless of what was touched, and until
#6459 that job's checkout had no fetch-depth: 0. That is exactly why #6356
zero spec files touched — went red on TypeScript Type Check while Build Core
stayed green. Informational, as the ruling asked; it does not change the fix, but
it does mean the fallback is on the hot path of every PR rather than of
spec-touching ones.

Tests

Five new cases in build-schemas-check-mode.test.ts's git sandbox, all under
.git/shallow, plus one existing case re-based.

  • no false red: a key main added after the fork is not reported as this branch deleting it — acceptance (i)
  • the gate does not weaken: a genuine deletion is still red under the same truncation — acceptance (ii)
  • the baseline keys come from git at that commit, never from the anchor file (no self-verification) — acceptance (iii)
  • an anchor rev nothing upstream vouches for is not used — the rev origin/main records is — the P1 repair
  • with no upstream anchor at all it keeps the tip and says so, naming fetch-depth: 0 — the P2 honest degradation, pinned so it stays loud

Re-based, deliberately: #5847's claims NO direction in a shallow checkout.
Its fixture depended on the shallow baseline being origin/main's tip, which is the
thing this PR changes. Its subject is unchanged — the notice must still refuse to
name a direction it could not measure — so the fixture now states on main which rev
the anchor names, and grafts both fetched revs, which is what a checkout that
fetched two commits at --depth=1 actually looks like. Neither ancestry probe can
then answer, and the case asserts the same refusal it always did.

Run after merging origin/main (which brought #6459 plus four spec PRs):

pnpm --filter @objectstack/spec exec vitest run scripts/build-schemas-check-mode.test.ts
  Test Files  1 passed (1)       Tests  60 passed (60)

pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2
  Test Files  339 passed (339)   Tests  8684 passed (8684)

pnpm --filter @objectstack/spec typecheck        # tsc + check:scripts-typecheck + check:test-typecheck
pnpm --filter @objectstack/spec build && … check:generated
  ✓ All 10 generated artifacts are up to date.

Every check:* step enumerated from lint.yml was run one by one — both jobs,
including today's new check:scripts-typecheck (which runs inside
@objectstack/spec's typecheck script) — plus the full workspace
turbo run build / turbo run typecheck, check:type-check-debt, the examples /
downstream-contract typechecks, check:doc-formula-expressions, check:i18n and
check:i18n-coverage. All green.

No changeset — skip-changeset

packages/spec's published files whitelist is
["dist","json-schema","liveness","prompts","llms.txt","README.md","src/**/*.zod.ts","CHANGELOG.md","api-surface","spec-changes.json"]
scripts/ is not in it, and check:published-files enforces that each
package's own scripts/** is never runtime code (#4206/#4248). So this change
ships to no consumer of @objectstack/spec: it is build-time gate machinery, and
the only audience is this repo's own CI and contributors. Nothing to release, no
migration to prescribe, so skip-changeset rather than an empty changeset. The
label is applied on this PR.

Files

  • packages/spec/scripts/build-schemas.ts
  • packages/spec/scripts/build-schemas-check-mode.test.ts

No packages/spec/src/**, no generated artifacts, no content/docs/releases/**,
no workflow files — lint.yml's fetch-depth: 0 was #6359's half and landed in
#6459, which this branch merged rather than re-did.

…base cannot answer (#6452)

`resolveSurfaceBase()` fell back to origin/main's TIP whenever
`merge-base HEAD origin/main` failed. Under a tip anchor "main added a key
after this branch forked" and "this branch deleted a key" are the same fact,
so the gate reported the first as the second (#6359: PR #6356 touched no
packages/spec file and was told it had deleted ui/BulkActionDef:requiredPermissions).

The anchor moves; the verdict does not. When merge-base cannot answer, the
baseline rev now comes from an upstream anchor rev and its keys are read out
of git at that commit — never out of authorable-surface.base.json itself.
The rev is accepted only when something the PR does not control vouches for
it: demonstrated reachability, or origin/main's own committed copy of the
anchor naming the same commit; otherwise the rev origin/main records is used.
With no upstream anchor at all the tip is kept and the run says so, naming
`fetch-depth: 0`.

Also folds verifyCommittedSurfaceBase's open-coded ancestry read onto the
shared `probeAncestry` (#5370/#5847), so git DECLINING to answer is no longer
read as a verdict of "not an ancestor".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZgRyPVwi1jLb1mNNuUQ9o
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 7, 2026 11:23pm

Request Review

@os-project-manager os-project-manager added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 7, 2026 — with Claude
#6459 (#6359's stop-gap) landed the shallow diagnostic this PR's fallback
replaces. Resolution keeps the re-anchor branch and hands #6359's wording to the
one arm it still describes — the tip fallback, reached only when no upstream
anchor is usable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZgRyPVwi1jLb1mNNuUQ9o
@github-actions github-actions Bot added the size/l label Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

No hand-written docs reference the 0 changed package(s). ✅

@os-project-manager
os-project-manager marked this pull request as ready for review August 7, 2026 23:48
@os-project-manager
os-project-manager added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit 747d2e4 Aug 8, 2026
27 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-6452-surface-base-shallow-anchor branch August 8, 2026 00:04
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

Development

Successfully merging this pull request may close these issues.

resolveSurfaceBase() 的 tip fallback 是静默的正确性降级 —— 拿不到 merge base 时它照样把「删除」判成违规并 exit 1

1 participant