Prove every ext-state write by reading the key back, so no guard is constant-true

SetProjExtState's return describes the whole extname. The persist and the instrument's publish now re-read their key; both refusals can finally fire.
This commit is contained in:
2026-08-02 13:14:49 -04:00
parent 0afb885987
commit 25390d5253
14 changed files with 231 additions and 112 deletions
+79 -32
View File
@@ -9,11 +9,13 @@
// 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; 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.
// the persist/upgrade state machine that is the ONLY route to a Banked landing, including
// the reachability of its Unpersisted limb at both persist outcomes.
#include "../src/core/wire/bake_wire.h"
#include "../src/core/version/app_version.h"
#include "../src/core/wire/ext_state_read.h" // extStateWriteLanded (the write-proof peer)
#include <cstdint>
#include <cstdio>
@@ -31,8 +33,8 @@ static int g_fail = 0;
// 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";
"the bake reached the bank in memory, but this pass's persist did not confirm its bank "
"write, so this answer cannot promise a reload will find it";
int main() {
// --- The exact bytes on the wire -------------------------------------------------
@@ -412,13 +414,7 @@ int main() {
clean.keysFound = 1;
clean.activeTabKeys = 1;
clean.answered = 1;
clean.landed = 1;
CHECK(describeBakeScan(clean).empty());
// A refusal is still an answer, so it silences the summary the same way a landing
// does — the refusal's own sentence has already been printed.
BakeScanTally refusedOne = clean;
refusedOne.landed = 0;
CHECK(describeBakeScan(refusedOne).empty());
// THE regression: some OTHER key was answered while ours was skipped. Under the old
// `answered > 0` gate this printed nothing at all, and the instrument then told the
@@ -557,11 +553,11 @@ 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);
// The sentence names the OBSERVATION behind the flag (see BakeLanding) and stops
// there — it may not claim the .rpp on disk already holds the entry.
CHECK(ok.find("read back as exactly what this pass wrote") != std::string::npos);
CHECK(ok.find(".rpp") == std::string::npos);
CHECK(ok.find("carries it") == 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.
@@ -589,7 +585,7 @@ 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("persist did not report success") != std::string::npos);
CHECK(memoryOnly.find("persist did not confirm its bank write") != 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
@@ -633,9 +629,12 @@ int main() {
BakeKeyOutcome clearLost = cleared;
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("the clear did NOT take") != std::string::npos);
CHECK(stuck.find("next pass will see it again") != std::string::npos);
CHECK(stuck.find("it was cleared") == std::string::npos);
// Rejected also covers a clear that was never issued, so the line may not describe
// a read that did not happen.
CHECK(stuck.find("read back") == 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;
@@ -739,12 +738,11 @@ int main() {
}
// --- A rejected write is REACHABLE, and it is what the tally and the line come from ---
// The shell used to judge this by SetProjExtState's return, which is the size of the
// WHOLE extname's state — `banks` alone keeps that non-zero in every case a bake can
// reach, so `writeFailed` could not be produced at all and the sentence for it was dead
// code. The verdict is now the read-back below, which a store that drops the write does
// produce. The store is modelled here; the shell binds these same two calls to
// SetProjExtState and its existing grow-loop GetProjExtState read.
// Judging this by SetProjExtState's return made `writeFailed` unproducible on the
// landing path and its sentence dead code (extStateWriteLanded owns why). The verdict is
// the read-back, which a store that drops the write does produce. The store is modelled
// here; the three shells bind these same two calls to SetProjExtState and the
// grow-loop read.
{
struct FakeKeyStore {
std::map<std::string, std::string> values;
@@ -778,7 +776,7 @@ int main() {
rejecting.values[key] = encodeBakeRequest(pending);
rejecting.dropWrites = true;
rejecting.write(key, answer);
CHECK(!bakeWriteLanded(answer, rejecting.read(key)));
CHECK(!extStateWriteLanded(answer, rejecting.read(key)));
// ...which is exactly what the ASKING end reads as a no-answer. The two classifiers
// agree about this one state, which is why the extension has to name it.
CHECK(classifyBakeAnswer(rejecting.read(key), pending).kind ==
@@ -790,13 +788,12 @@ int main() {
tally.keysFound = 1;
tally.activeTabKeys = 1;
tally.answered = 1;
tally.landed = 1;
BakeKeyOutcome report;
report.verdict = BakeScanVerdict::Land;
// The shell reaches Banked only through the upgrade — never by assignment.
report.landing = bakeLandingAfterPersist(BakeLanding::Unpersisted, true);
report.detail = landedOk.message;
report.proof = bakeWriteLanded(answer, rejecting.read(key))
report.proof = extStateWriteLanded(answer, rejecting.read(key))
? BakeWriteProof::Confirmed
: BakeWriteProof::Rejected;
if (report.proof == BakeWriteProof::Rejected) ++tally.writeFailed;
@@ -811,7 +808,7 @@ int main() {
FakeKeyStore accepting;
accepting.values[key] = encodeBakeRequest(pending);
accepting.write(key, answer);
CHECK(bakeWriteLanded(answer, accepting.read(key)));
CHECK(extStateWriteLanded(answer, accepting.read(key)));
CHECK(classifyBakeAnswer(accepting.read(key), pending).kind ==
BakeAnswerKind::Answered);
BakeScanTally clean = tally;
@@ -823,21 +820,71 @@ int main() {
FakeKeyStore clearing;
clearing.values[key] = encodeBakeRequest(pending);
clearing.write(key, "");
CHECK(bakeWriteLanded("", clearing.read(key)));
CHECK(extStateWriteLanded("", clearing.read(key)));
FakeKeyStore clearDropped;
clearDropped.values[key] = encodeBakeRequest(pending);
clearDropped.dropWrites = true;
clearDropped.write(key, "");
CHECK(!bakeWriteLanded("", clearDropped.read(key)));
CHECK(!extStateWriteLanded("", clearDropped.read(key)));
// Byte equality is the claim the line makes: a truncated or foreign value under the
// key is not the answer we wrote, and an unreadable key proves nothing at all.
CHECK(!bakeWriteLanded(answer, std::optional<std::string>(
CHECK(!extStateWriteLanded(answer, std::optional<std::string>(
answer.substr(0, answer.size() - 1))));
CHECK(!bakeWriteLanded(answer, std::optional<std::string>(
CHECK(!extStateWriteLanded(answer, std::optional<std::string>(
encodeBakeRequest(pending))));
CHECK(!bakeWriteLanded(answer, std::nullopt));
CHECK(!bakeWriteLanded("", std::optional<std::string>("leftover")));
CHECK(!extStateWriteLanded(answer, std::nullopt));
CHECK(!extStateWriteLanded("", std::optional<std::string>("leftover")));
}
// --- The Unpersisted limb is REACHABLE, through the same predicate ------------------
// `persisted` used to be structurally true wherever a landing could observe it: the
// session reported it from SetProjExtState's unread return, and by the time a landing
// exists the only two conditions that return could reflect (no active project, an
// unsaved one) are already excluded by the Land verdict. So the Unpersisted limb, the
// wire answer for it and its console clause were all dead. The session now proves the
// `banks` key the same way the landing proves its own — modelled below at both
// outcomes, so neither branch is a constant.
{
const std::string banksJson = R"({"banks":[{"id":"pool","samples":[]}]})";
// The write REJECTED: the project still holds whatever it held before, which is a
// book without the entry this pass just landed in memory.
const std::optional<std::string> stale{R"({"banks":[]})"};
const bool persistedAfterDrop = extStateWriteLanded(banksJson, stale);
CHECK(!persistedAfterDrop);
BakeKeyOutcome entry;
entry.verdict = BakeScanVerdict::Land;
entry.landing =
bakeLandingAfterPersist(BakeLanding::Unpersisted, persistedAfterDrop);
entry.proof = BakeWriteProof::Confirmed; // the ANSWER wrote fine; the persist did not
CHECK(entry.landing == BakeLanding::Unpersisted);
const std::string line = describeBakeKey("rsbake_0123abcd", entry);
CHECK(line.find("IN MEMORY ONLY") != std::string::npos);
CHECK(line.find("The answer was written back") != std::string::npos);
// ...and that landing is answered as a FAILURE on the wire, so no instance adopts
// an entry a reload would not find.
BakeOutcome answered;
answered.status = BakeStatus::Failed;
answered.message = kUnpersistedAnswer;
answered.generation = 1893456000;
const auto back = decodeBakeOutcome(encodeBakeOutcome(answered));
CHECK(back.has_value() && back->status == BakeStatus::Failed);
CHECK(back.has_value() && back->message == kUnpersistedAnswer);
// The write ACCEPTED, same predicate, same inputs but for the read-back: the limb
// above is a real branch, not a constant.
const bool persistedAfterTake =
extStateWriteLanded(banksJson, std::optional<std::string>(banksJson));
CHECK(persistedAfterTake);
CHECK(bakeLandingAfterPersist(BakeLanding::Unpersisted, persistedAfterTake) ==
BakeLanding::Banked);
// An unreadable read-back must NOT be folded in as an absence-and-therefore-a-clear:
// the session routes Overflow to false before it ever reaches this predicate, and a
// non-empty write against an absent key is false here regardless.
CHECK(!extStateWriteLanded(banksJson, std::nullopt));
}
// --- Every outcome bake_land actually emits survives the key round trip -------------