Skip to content

fix(upgrade): reject stale current playbook - #541

Merged
taco3064 merged 7 commits into
mainfrom
fix/540-stale-upgrade-playbook
Sep 22, 2026
Merged

taco3064 merged 7 commits into
mainfrom
fix/540-stale-upgrade-playbook

Conversation

@taco3064

@taco3064 taco3064 commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

Closes #540.

What changed

  • gather the repository-root blueprint-upgrade.md as a dedicated upgrade fact
  • when the durable lifecycle is already at the running target with pending: null, preserve and explicitly refuse contradictory stale residue
  • keep active pending resume, older lifecycle upgrades, Fix 4.1 pre-release field findings before final convergence #532 finalization, and remove behavior unchanged

Final precedence model

The current head uses one preflight rule instead of globally reordering refusal functions:

  • stale-current with no active authoring/transformation workflow → stale-upgrade-playbook
  • every other case → the original readiness order remains config-unreadable → not-adopted → pending-workflow

This keeps stale residue ahead of config/install repair only where #540 requires it, while preserving the complete pre-existing workflow safety surface.

Evidence

  • focused precedence/upgrade suites: 49 tests passed
  • lint-staged and focused ESLint passed
  • full outgoing gate: TypeScript passed; 288 test files / 4152 tests passed; 100% statements, branches, functions, and lines
  • stale/no-stale × workflow/no-workflow × unreadable/unadopted/adopted boundaries have exact result assertions
  • older/missing install controls prove no install or handoff and byte-identical playbook/lifecycle/install state
  • same-target active pending resumes; older lifecycle with playbook proceeds normally

Independent Acceptance

  • verdict: ACCEPTED
  • repair base: 0fe648fd988361da0c08077f761ceebec1a97f42
  • candidate tree: 00ca6198d8ca92a98b1135ee620a8dca83b77440
  • remote head: 2a1b3ebc88a2424039893abbcca05658a5dd3b9c
  • remote head tree matches the accepted tree

PR CI and changed-code mutation remain authoritative for this exact head.

@taco3064 taco3064 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CHANGES_REQUIRED

Reviewed exact head 7719584269ef58c1de561e2a3885db100dab1813 against #540.

Candidate identity and mechanical verification are clean:

  • head tree: e078850a860f8f59041d823ef211673562c42a12
  • PR body records independent Accept: ACCEPTED for that exact tree
  • exact-head CI #897: success
  • Windows / Ubuntu / Node 18 / ESLint 10 / operational / docs / topology replay: success
  • changed-production mutation shard + aggregate: success
  • PR verification: success

The direct R5 reproducer is fixed correctly: a fully settled current lifecycle with stale blueprint-upgrade.md now fails, names the artifact, preserves both playbook and lifecycle, and the same behavior is covered under --dry-run.

I found one remaining decision-fidelity blocker.

A current lifecycle with install drift still bypasses the stale-playbook refusal and can later delete that playbook automatically

#540's product decision is keyed by the durable lifecycle already being current while a stale blueprint-upgrade.md exists. In that contradictory state Blueprint must preserve the playbook, report it, and require manual review/removal. The ticket explicitly says not to auto-delete or reinterpret it as recoverable semantic work.

The current implementation narrows the refusal further:

if (input.facts.checkpoint.kind === 'state' && settled
  && input.facts.upgradePlaybook !== null) {
  return refuse(...)
}

So the stale artifact is refused only when every installed application already matches the running target.

The new decision test intentionally codifies the opposite behavior for install drift:

current lifecycle
+ stale blueprint-upgrade.md
+ installed 4.0.0 or missing
→ proceed(mode: start)

That is not just an untested edge. Following the existing upgrade flow, this path records an empty pending repair, repairs the install, then completion calls recordCompletion(); its default retire is removePlaybook(), which does:

fs.rmSync(path.join(root, UPGRADE_PLAYBOOK_FILE), { force: true });

So a stale playbook that #540 says is contradictory/user-review-required can be silently consumed by an install-repair path merely because the installed package drifted from the already-current lifecycle.

Please keep install repair behavior, but make stale-playbook ownership authoritative whenever the durable lifecycle itself is current. In other words:

lifecycle says current
+ stale blueprint-upgrade.md
→ preserve + explicit non-zero refusal

regardless of whether the local installed package currently needs repair

The operator can remove/review the stale artifact first, then rerun upgrade and let the existing install-repair path do its normal work.

Add a focused control for at least:

  • current lifecycle + stale playbook + older installed package;
  • current lifecycle + stale playbook + missing installed package;

proving no lifecycle/install mutation runs and the playbook remains byte-for-byte unchanged.

I do not currently see another #540 requirement-fidelity blocker on this head.

@taco3064 taco3064 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CHANGES_REQUIRED

Re-reviewed exact head 2d1e55be03c407220954899a64a3a73d30702c5d against #540.

The previous install-drift blocker is fixed correctly. Current lifecycle + stale blueprint-upgrade.md now refuses before older/missing package repair, preserves lifecycle/playbook/install state, and same-target pending !== null still resumes normally.

I found one remaining requirement-fidelity blocker.

The new refusal priority changes existing authoring / transformation workflow refusal behavior

#540 explicitly requires:

Existing authoring/transformation workflow refusal behavior remains unchanged.

The current decision order is:

catalogRefusal(...)
?? stalePlaybookRefusal(...)
?? readinessRefusal(...)
?? checkpointRefusal(...)
?? historyRefusal(...)

But readinessRefusal() is where existing facts.workflows becomes:

{ kind: 'pending-workflow', files: facts.workflows }

So this repository state:

lifecycle = 4.1.0
pending = null
blueprint-upgrade.md exists
blueprint-authoring.md or blueprint-transformation.json also exists

now returns stale-upgrade-playbook first instead of the existing pending-workflow refusal.

That changes a pre-existing workflow safety surface even though #540 deliberately says not to broaden or replace it. The stale playbook should outrank config/install readiness so the missing/older-package case stays fixed, but it should not steal authority from an actual active authoring/transformation workflow.

Please preserve the existing workflow refusal priority while keeping the repaired install-drift behavior. A narrow shape would be conceptually:

active authoring / transformation workflow
→ existing pending-workflow refusal

otherwise, current lifecycle + stale upgrade playbook
→ stale-upgrade-playbook refusal

then config/install readiness
→ existing behavior

Add a focused control proving that current lifecycle + stale blueprint-upgrade.md + an active authoring/transformation workflow still returns the existing pending-workflow refusal and preserves all files.

The exact-head CI #900 is still running at review time; all completed jobs, including the mutation aggregate, are green so far. This blocker is independent of CI status.

I do not currently see another #540 requirement-fidelity blocker on this head.

@taco3064 taco3064 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CHANGES_REQUIRED

只看程式,不看 CI。

上一個 blocker 已修掉:current lifecycle + stale playbook + active authoring/transformation workflow 現在會回既有的 pending-workflow,而 stale playbook 不會搶掉它。

但這次把 workflowRefusal() 整體拉到 readinessRefusal() 前面,造成一個新的 collateral behavior change。

原本 #540 之前的順序在同一個 readinessRefusal() 裡是:

config-unreadable
→ not-adopted
→ pending-workflow

現在變成:

catalogRefusal(...)
?? workflowRefusal(facts)
?? stalePlaybookRefusal(...)
?? readinessRefusal(input)

因此即使 沒有 stale playbook,只要 repo 同時有 active workflow:

  • config unreadable + workflow:以前回 config-unreadable,現在變成 pending-workflow
  • no adopted application + workflow:以前回 not-adopted,現在也變成 pending-workflow

這是 #540 之外的既有 refusal precedence 改動,而且 ticket 明確要求 existing authoring/transformation workflow refusal behavior 不要被改。

不要再把 workflow refusal 全域升權。比較窄的做法是:

如果有 stale-current residue:
  有 active workflow
  → 仍沿用舊 readiness precedence 決定是否真的該回 pending-workflow
  沒有 active workflow
  → stale-upgrade-playbook 可優先於 config/install readiness

如果沒有 stale-current residue:
  → 完整維持原本 readinessRefusal() 行為與順序

也就是修 #540 的 precedence 應該只影響 stale-current 這個新分支,不該重排所有 upgrade refusal。

請補至少兩個 regression controls:

  1. no stale playbook + config unreadable + active workflow → 仍是 config-unreadable
  2. no stale playbook + no adopted application + active workflow → 仍是 not-adopted

目前除此之外,我沒有看到新的 #540 blocker。

@taco3064 taco3064 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CHANGES_REQUIRED

只看程式,不看 CI。

上一輪要求的「不要全域重排 refusal priority」已經修回來了:沒有 stale-current residue 時,readinessRefusal() 仍維持原本:

config-unreadable
→ not-adopted
→ pending-workflow

但 stale-current + active workflow 這個分支還差最後一個 precedence 邊界。

現在 stalePlaybookRefusal() 直接做:

return state.blueprint === target && state.pending === null
  && facts.upgradePlaybook !== null
  ? facts.workflows.length
    ? refuse({ kind: 'pending-workflow', files: facts.workflows })
    : refuse({ kind: 'stale-upgrade-playbook', file: facts.upgradePlaybook })
  : null;

而它仍然在 readinessRefusal() 前面。

所以當 stale-current residue 與 active workflow 同時存在時:

  • config unreadable + workflow + stale playbook → 現在回 pending-workflow,舊 precedence 應該仍是 config-unreadable
  • no adopted application + workflow + stale playbook → 現在回 pending-workflow,舊 precedence 應該仍是 not-adopted

這正是上一輪 review 裡「有 active workflow 時,仍沿用舊 readiness precedence 決定是否真的該回 pending-workflow」尚未完全落地的部分。

目前新增的 runtime control只覆蓋:

current + stale + active workflow
+ config readable
+ adopted
→ pending-workflow

這條是對的,但還不足以證明既有 workflow refusal behavior 完整保持。

建議把判斷收成:

stale-current + active workflow
→ 先走原本 readiness precedence
   config-unreadable / not-adopted / pending-workflow

stale-current + no active workflow
→ stale-upgrade-playbook

no stale-current
→ 原本 readinessRefusal() 完全不變

請補兩個 focused controls:

  1. stale-current + config unreadable + active workflow → config-unreadable
  2. stale-current + no adopted application + active workflow → not-adopted

除此之外,我目前沒有看到新的 #540 blocker。

@taco3064 taco3064 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVED

只看程式,不看 CI。

這一版把前幾輪的 precedence 問題收乾淨了。

目前 preflightRefusal() 的行為是:

stale-current + no active workflow
→ stale-upgrade-playbook

stale-current + active workflow
→ 回到原本 readinessRefusal()
   config-unreadable
   → not-adopted
   → pending-workflow

no stale-current
→ 完整沿用原本 readinessRefusal()

這符合 #540 的窄範圍要求,也沒有再全域重排既有 upgrade refusal。

我重新檢查了幾個前面出過問題的邊界:

  • current + stale + installed current → 明確拒絕 stale playbook
  • current + stale + installed older / missing → stale refusal 先於 install repair,不會偷偷消耗 playbook
  • current + stale + active workflow → 保留既有 workflow safety surface
  • current + stale + active workflow + config unreadable → config-unreadable
  • current + stale + active workflow + no adopted app → not-adopted
  • no stale + active workflow → 原本 readiness precedence 不變
  • same-target pending !== null + playbook → 正常 resume,不被誤判 stale
  • older lifecycle + playbook → 正常進 upgrade plan,不把合法/既有 playbook 誤判成 current residue
  • bootstrap / pre-lifecycle state + playbook → 不會套用 #540 的 stale-current refusal

stalePlaybookRefusal() 也只在:

state present
+ state.blueprint === running target
+ pending === null
+ blueprint-upgrade.md exists

時成立,邊界夠窄,沒有碰 #532 completion ordering,也沒有自動刪除或重寫 playbook。

目前從程式與 #540 decision fidelity 來看,我沒有再看到 blocker。

Shaper 結論:APPROVED。

@taco3064
taco3064 marked this pull request as ready for review September 22, 2026 14:12
@taco3064
taco3064 merged commit 90d2a27 into main Sep 22, 2026
13 checks passed
@taco3064
taco3064 deleted the fix/540-stale-upgrade-playbook branch September 22, 2026 14:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fail current upgrade when stale blueprint-upgrade.md remains

1 participant