fix(spec): re-anchor the authorable-surface deletion gate when merge-base cannot answer (#6452) - #6461
Merged
os-project-manager merged 2 commits intoAug 8, 2026
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
#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
Contributor
📓 Docs Drift CheckNo hand-written docs reference the 0 changed package(s). ✅ |
os-project-manager
marked this pull request as ready for review
August 7, 2026 23:48
os-project-manager
deleted the
claude/issue-6452-surface-base-shallow-anchor
branch
August 8, 2026 00:04
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #6452
Adopts the seat ruling's third path: move the anchor, not the verdict. When
merge-base HEAD origin/maincannot 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.jsonitself.The defect, restated from the code
resolveSurfaceBase()carriedUnder 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/specfile at all andwas told it had deleted
ui/BulkActionDef:requiredPermissions, a key main hadjust added.
Verified against
origin/main@f1850d8before implementing — all of theissue's blast-radius claims still hold verbatim:
origin/main@f1850d8build-schemas.ts:1526-1527, byte-identical to the quote--checkif (CHECK)process.exit(1)in that same blockgen:schemais part ofbuild"build": "pnpm gen:schema && pnpm gen:openapi && tsup …"A timing note, since it changed mid-task: when this branch was cut at
f1850d8the half the issue describes as already landed ("#6359 的 PR 里 … shallow 那行日志
现在点名方向") was not on
main—lint.yml'stypecheckjob still had nofetch-depth: 0, and the shallow log line was still the original one-liner. #6459landed 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-basefails, the rev comes from an upstream anchor, accepted onlywhen something the PR does not control vouches for it, in this order:
probeAncestryanswersyes, which is proofin every checkout (a cut walk can only lose reachability, never invent it);
authorable-surface.base.jsonpoints at it. This is the ordinary case: theanchor moves only under an explicit
--update-base(check:authorable-surface在--check模式下也会重写authorable-surface.base.json—— 一次纯核验会改工作区,且任何无关 PR 都能因此静默推进删除门的锚点 #5358);With none of those available the tip is kept and the run says so, naming
fetch-depth: 0. Keys always come fromreadSurfaceKeysAtRevat the chosen rev.--update-baseis deliberately excluded from the fallback: its job is to resolvea 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'sopen-coded
merge-base --is-ancestorread is folded onto the sharedprobeAncestry. 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.
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.
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 deletedkey and not main's addition.
added after the anchored rev. Those cannot be deletions by this branch: the
branch never had them.
this is where the PR goes beyond the ruling's letter, to keep its intent.
Premise checks (measured, one falsified)
P1 —
baseRevis a verified ancestor of origin/main: FALSIFIED in exactly theenvironment 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:
The fetched commit is grafted in as its own shallow root, so the ancestry probe
returns a false negative and
probeAncestrycorrectly reportsunknown. Thatmatters because today the skip is free —
resolveSurfaceBase's own doc commentrecords 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
baseRevat one of its owncommits (a
--depth=1fetch resolves any sha the remote advertises, its own headincluded) 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=1fetch of main carries the tip's whole tree). Pinnedby the case
an anchor rev nothing upstream vouches for is not used.P2 — fetching the single
baseRevfrom a shallow checkout: CONFIRMED (exit 0above, 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 thecase
with no upstream anchor at all it keeps the tip and says so. A diagnosablefalse red beats a silent bypass; the fully-offline route (#5235) is untouched,
since it is reached only when
origin/maindoes not resolve at all.P3 — reuse #6309's
probeAncestry/relateAnchorToBaseline: CONFIRMED andapplied.
probeAncestrycovers what this fallback needs (its documentedasymmetry — 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.
relateAnchorToBaselineneeded no change.#6360's authorable-defaults fingerprint gate: same mechanism, fixed in the same
stroke. It resolves its baseline through
resolvedSurfaceBase.rev, i.e. thevery value this PR re-anchors — as does
checkManifestRemovals(#4725), throughgitResolvedAnchor.rev. No separate card is needed; all three anchor consumersmove 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):
R2 — take the baseline keys from the anchor FILE (the prohibited shortcut).
Predicted: the self-verification case flips from red to GREEN, because
verifyCommittedSurfaceBase'srev === resolved.revfast path would then comparethe 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 theacceptance 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:buildhashb77913c997fea8a7packages/core/src/index.tsb77913c997fea8a7(unchanged, cache hit)packages/spec/scripts/build-schemas.tse7ab39537148d444(miss)So the issue's claim is confirmed for turbo-driven
buildtasks: the taskdeclares no
inputs, so it defaults to the package's 876 tracked files, and a PRtouching nothing under
packages/specgets a cache hit and never executesgen: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'sTypeScript Type Checkjob runspnpm --filter @objectstack/spec check:authorable-surfaceas a direct scriptstep, not a turbo task, so turbo caching gives it no protection at all: it runs
build-schemas.ts --checkon 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 CheckwhileBuild Corestayed 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 repairwith no upstream anchor at all it keeps the tip and says so, naming fetch-depth: 0— the P2 honest degradation, pinned so it stays loudRe-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=1actually looks like. Neither ancestry probe canthen answer, and the case asserts the same refusal it always did.
Run after merging
origin/main(which brought #6459 plus four spec PRs):Every
check:*step enumerated fromlint.ymlwas run one by one — both jobs,including today's new
check:scripts-typecheck(which runs inside@objectstack/spec'stypecheckscript) — plus the full workspaceturbo run build/turbo run typecheck,check:type-check-debt, the examples /downstream-contract typechecks,
check:doc-formula-expressions,check:i18nandcheck:i18n-coverage. All green.No changeset —
skip-changesetpackages/spec's publishedfileswhitelist 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, andcheck:published-filesenforces that eachpackage's own
scripts/**is never runtime code (#4206/#4248). So this changeships to no consumer of
@objectstack/spec: it is build-time gate machinery, andthe only audience is this repo's own CI and contributors. Nothing to release, no
migration to prescribe, so
skip-changesetrather than an empty changeset. Thelabel is applied on this PR.
Files
packages/spec/scripts/build-schemas.tspackages/spec/scripts/build-schemas-check-mode.test.tsNo
packages/spec/src/**, no generated artifacts, nocontent/docs/releases/**,no workflow files —
lint.yml'sfetch-depth: 0was #6359's half and landed in#6459, which this branch merged rather than re-did.