Make a bake's Banked landing reachable only through the persist upgrade

The dedup path claimed it directly, so a key could be answered Ok pointing at an entry a failed persist never wrote. Write-back is now guarded and three-valued, and landing one request split out of the scan pass.
This commit is contained in:
2026-08-02 12:35:06 -04:00
parent 1800bd64c3
commit 0afb885987
10 changed files with 664 additions and 371 deletions
+139 -15
View File
@@ -7,8 +7,9 @@
// break a delimiter-based format; the refusals every house wire record shares (wrong tag,
// truncation, trailing garbage, a swapped record kind); an unrecognized status integer
// degrading to Failed rather than to Ok; the action lookup name's leading underscore and
// its channel fork; the two key classifiers each end reads the shared key through; and the
// write-back verdict, driven by a modelled key store that accepts or drops the write.
// its channel fork; the two key classifiers each end reads the shared key through; the
// write-back verdict, driven by a modelled key store that accepts or drops the write; and
// the persist/upgrade state machine that is the ONLY route to a Banked landing.
#include "../src/core/wire/bake_wire.h"
@@ -26,6 +27,13 @@ static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// The one answer bake_land emits for a landing its pass could not persist. Pinned once
// here and used by both blocks below that need it, so the test suite does not become a
// third place the sentence lives.
static const std::string kUnpersistedAnswer =
"the bake reached the bank in memory, but this pass's persist did not report success, "
"so this answer cannot promise a reload will find it";
int main() {
// --- The exact bytes on the wire -------------------------------------------------
// A round-trip alone would pass a reordered or inserted field; the tags exist to guard
@@ -442,6 +450,24 @@ int main() {
CHECK(describeBakeScan(rejectedTwo).find("2 answers could not be written back") !=
std::string::npos);
// An answer whose write-and-check THREW is neither landed nor known-failed, and it
// must break the silence too: the whole point of the gate is that no fault in the
// pass leaves the asking instance with a no-answer and an empty console.
BakeScanTally unproven = clean;
unproven.writeUnproven = 1;
const std::string unsure = describeBakeScan(unproven);
CHECK(!unsure.empty());
CHECK(unsure.find("1 answer could not be checked after writing") !=
std::string::npos);
CHECK(unsure.find("is unknown") != std::string::npos);
// ...and it is NOT reported as a rejection, which is a different claim.
CHECK(unsure.find("could not be written back") == std::string::npos);
BakeScanTally unprovenTwo = clean;
unprovenTwo.answered = 2;
unprovenTwo.writeUnproven = 2;
CHECK(describeBakeScan(unprovenTwo).find("2 answers could not be checked") !=
std::string::npos);
// Nothing found at all: the observation is stated and the candidates listed, with
// no winner picked — the enumeration's visibility of a key set but not yet saved is
// undocumented, so "not the same project's ext state" may not name it.
@@ -521,7 +547,7 @@ int main() {
BakeKeyOutcome landed;
landed.verdict = BakeScanVerdict::Land;
landed.landing = BakeLanding::Banked;
landed.writeConfirmed = true;
landed.proof = BakeWriteProof::Confirmed;
landed.detail = "added as a distinct capture";
const std::string ok = describeBakeKey(key, landed);
CHECK(ok.find(key) != std::string::npos);
@@ -531,16 +557,31 @@ int main() {
// The outcome's own reason rides the SAME line: one self-contained line per key,
// rather than a keyed verdict and an unkeyed reason the reader has to pair up.
CHECK(ok.find("(added as a distinct capture)") != std::string::npos);
// Banked claims the persist FLAG and nothing more, never that the .rpp already
// holds it — saveToActiveProject issues its SetProjExtState calls without reading
// their returns, and project ext state reaches the file on the project's own save.
CHECK(ok.find("persist reported success") != std::string::npos);
CHECK(ok.find("once the project is saved") != std::string::npos);
// A landing whose answer write was REJECTED — the state the instrument reads as
// Unanswered. This line is the only place it is ever named.
BakeKeyOutcome lost = landed;
lost.writeConfirmed = false;
lost.proof = BakeWriteProof::Rejected;
const std::string dropped = describeBakeKey(key, lost);
CHECK(dropped.find("landed into the bank") != std::string::npos);
CHECK(dropped.find("could NOT be written back") != std::string::npos);
CHECK(dropped.find("will report no answer") != std::string::npos);
// The write-and-check itself failing is a THIRD state, not a rejection: claiming
// the answer did not land would be a fact this pass never observed.
BakeKeyOutcome unchecked = landed;
unchecked.proof = BakeWriteProof::Unknown;
const std::string unsure = describeBakeKey(key, unchecked);
CHECK(unsure.find("is unknown") != std::string::npos);
CHECK(unsure.find("could NOT be written back") == std::string::npos);
CHECK(unsure.find("The answer was written back") == std::string::npos);
CHECK(unsure != ok && unsure != dropped);
// The four landing states are four different lines: a bake the project never
// persisted, and one that threw mid-write, may not read as a clean success or as a
// clean refusal.
@@ -548,8 +589,12 @@ int main() {
unpersisted.landing = BakeLanding::Unpersisted;
const std::string memoryOnly = describeBakeKey(key, unpersisted);
CHECK(memoryOnly.find("IN MEMORY ONLY") != std::string::npos);
CHECK(memoryOnly.find("could not persist it") != std::string::npos);
CHECK(memoryOnly.find("persist did not report success") != std::string::npos);
CHECK(memoryOnly != ok);
// It may NOT claim what the project's saved state holds: the persist can fail
// before writing anything or throw part-way, and a dedup hit's target may have
// been in the project since long before this pass.
CHECK(memoryOnly.find("does not carry it") == std::string::npos);
BakeKeyOutcome partial = landed;
partial.landing = BakeLanding::Partial;
@@ -562,7 +607,7 @@ int main() {
BakeKeyOutcome bankRefused;
bankRefused.verdict = BakeScanVerdict::Land;
bankRefused.landing = BakeLanding::Refused;
bankRefused.writeConfirmed = true;
bankRefused.proof = BakeWriteProof::Confirmed;
bankRefused.detail = "the bank refused the new capture";
const std::string refusedLine = describeBakeKey(key, bankRefused);
CHECK(refusedLine.find("the landing was refused") != std::string::npos);
@@ -571,7 +616,7 @@ int main() {
BakeKeyOutcome wrongTab;
wrongTab.verdict = BakeScanVerdict::RefuseWrongProject;
wrongTab.writeConfirmed = true;
wrongTab.proof = BakeWriteProof::Confirmed;
wrongTab.detail =
"this bake's project tab is not the one the extension has loaded -- focus that "
"tab and try again";
@@ -583,14 +628,21 @@ int main() {
// A clear is a write too: it may be claimed only where it was read back.
BakeKeyOutcome cleared;
cleared.verdict = BakeScanVerdict::ClearStale;
cleared.writeConfirmed = true;
cleared.proof = BakeWriteProof::Confirmed;
CHECK(describeBakeKey(key, cleared).find("cleared unanswered") != std::string::npos);
BakeKeyOutcome clearLost = cleared;
clearLost.writeConfirmed = false;
clearLost.proof = BakeWriteProof::Rejected;
const std::string stuck = describeBakeKey(key, clearLost);
CHECK(stuck.find("the clear could NOT be read back") != std::string::npos);
CHECK(stuck.find("next pass will see it again") != std::string::npos);
CHECK(stuck.find("it was cleared") == std::string::npos);
// And the clear's third state, same as an answer's: unchecked is not disproven.
BakeKeyOutcome clearUnchecked = cleared;
clearUnchecked.proof = BakeWriteProof::Unknown;
const std::string maybeCleared = describeBakeKey(key, clearUnchecked);
CHECK(maybeCleared.find("whether the clear took is unknown") != std::string::npos);
CHECK(maybeCleared.find("it was cleared unanswered") == std::string::npos);
CHECK(maybeCleared.find("could NOT be read back") == std::string::npos);
BakeKeyOutcome notRequest;
notRequest.verdict = BakeScanVerdict::IgnoreNotARequest;
@@ -613,6 +665,77 @@ int main() {
CHECK(describeBakeKey("rsbake_ffff0000", landed).find("rsbake_ffff0000") !=
std::string::npos);
CHECK(describeBakeKey("rsbake_ffff0000", landed) != ok);
// A LANDING this build has no word for must not be reported as a VERDICT gap: the
// two are different enums and send a reader to two different places.
BakeKeyOutcome futureLanding = landed;
futureLanding.landing = static_cast<BakeLanding>(99);
const std::string unnamed = describeBakeKey(key, futureLanding);
CHECK(unnamed.find("a landing state this build has no word for") !=
std::string::npos);
CHECK(unnamed.find("a verdict this build has no word for") == std::string::npos);
// A VERDICT gap still reports as one.
BakeKeyOutcome futureVerdict;
futureVerdict.verdict = static_cast<BakeScanVerdict>(99);
CHECK(describeBakeKey(key, futureVerdict).find(
"a verdict this build has no word for") != std::string::npos);
}
// --- The persist/upgrade state machine: the ONE route to Banked -----------------------
// The shell assigns every Land verdict's landing without knowing whether the pass's
// persist ran, then threads the flag through here. The bug this pins: a dedup hit used
// to be answered Banked directly, on the grounds that it changed nothing — false
// exactly when the entry it deduped against was one the SAME pass had just added and
// then failed to persist, which answers Ok for an entry the project does not carry.
{
// A dedup hit and a fresh add are INDISTINGUISHABLE here, by construction: the shell
// assigns Unpersisted to both, so both need the same observation to be promoted.
CHECK(bakeLandingAfterPersist(BakeLanding::Unpersisted, true) == BakeLanding::Banked);
CHECK(bakeLandingAfterPersist(BakeLanding::Unpersisted, false) ==
BakeLanding::Unpersisted);
// Exhaustive over both arguments: (Unpersisted, persisted) is the ONLY pair that
// produces Banked from anything else, and nothing else is altered at all — so a
// persist that ran cannot launder a refusal or a half-written landing into a
// success, and a persist that did not cannot demote one.
for (const BakeLanding from : {BakeLanding::Refused, BakeLanding::Partial,
BakeLanding::Unpersisted, BakeLanding::Banked}) {
for (const bool persisted : {false, true}) {
const BakeLanding to = bakeLandingAfterPersist(from, persisted);
const bool promotes = from == BakeLanding::Unpersisted && persisted;
CHECK(to == (promotes ? BakeLanding::Banked : from));
CHECK((to == BakeLanding::Banked) ==
(promotes || from == BakeLanding::Banked));
}
}
// A dedup hit driven end to end at both persist outcomes: what the instrument is
// told and what the console prints both follow the flag, and they never disagree.
for (const bool persisted : {true, false}) {
BakeKeyOutcome deduped;
deduped.verdict = BakeScanVerdict::Land;
deduped.landing = bakeLandingAfterPersist(BakeLanding::Unpersisted, persisted);
// A landing the pass could not persist is re-encoded as a FAILURE, not the Ok
// it was headed for, so no instance adopts an entry a reload may not find.
BakeOutcome answer;
answer.status = BakeStatus::Ok;
answer.sampleId = "bake-1";
answer.generation = 1893456000;
if (deduped.landing == BakeLanding::Unpersisted) {
answer.status = BakeStatus::Failed;
answer.message = kUnpersistedAnswer;
}
const auto back = decodeBakeOutcome(encodeBakeOutcome(answer));
CHECK(back.has_value());
CHECK(back.has_value() &&
back->status == (persisted ? BakeStatus::Ok : BakeStatus::Failed));
// The console line and the wire answer never disagree about the same key.
deduped.proof = BakeWriteProof::Confirmed;
const std::string line = describeBakeKey("rsbake_0123abcd", deduped);
CHECK((line.find("IN MEMORY ONLY") != std::string::npos) == !persisted);
}
}
// --- A rejected write is REACHABLE, and it is what the tally and the line come from ---
@@ -670,10 +793,13 @@ int main() {
tally.landed = 1;
BakeKeyOutcome report;
report.verdict = BakeScanVerdict::Land;
report.landing = BakeLanding::Banked;
// The shell reaches Banked only through the upgrade — never by assignment.
report.landing = bakeLandingAfterPersist(BakeLanding::Unpersisted, true);
report.detail = landedOk.message;
report.writeConfirmed = bakeWriteLanded(answer, rejecting.read(key));
if (!report.writeConfirmed) ++tally.writeFailed;
report.proof = bakeWriteLanded(answer, rejecting.read(key))
? BakeWriteProof::Confirmed
: BakeWriteProof::Rejected;
if (report.proof == BakeWriteProof::Rejected) ++tally.writeFailed;
CHECK(tally.writeFailed == 1);
CHECK(describeBakeKey(key, report).find("could NOT be written back") !=
std::string::npos);
@@ -769,9 +895,7 @@ int main() {
// adopted it would be pointing at something no reload will have.
BakeOutcome unpersisted = noProject;
unpersisted.status = BakeStatus::Failed;
unpersisted.message =
"the bake reached the bank in memory, but this pass could not persist it, so "
"the project's saved bank state does not carry it";
unpersisted.message = kUnpersistedAnswer;
BakeOutcome partial = noProject;
partial.status = BakeStatus::Failed;