R-302: the abandon banner promises only what the box can still see is true
gates / gates (push) Successful in 10s
gates / gates (push) Successful in 10s
The retrieval clause rendered unconditionally on every page and is false on a reachable state - the same screen where the orphan card says we cannot tell. The condition is a fingerprint PINNED at the decision, not a comparison against the current key. The obvious proxy asks about the wrong key: the set-aside copies were written under an older key the box no longer has, so on a twice-rebuilt box the proxy promises about copies nothing can open. Demonstrated - under the proxy, the replaced-package and legacy cases both flip back to promising. The pin is a recorded assumption and says so: nothing on the box records which key wrote those copies. Empty is not a match. A countdown started before this carries no pin and takes the cautious branch, not a backfill. A sweep of all 36 templates found a fourth instance (backups page, same condition applied) and a fifth (the confirmation screen, correctly left alone - true at the moment of the decision). New retrieval_promise_gate registers each claim with a reason rather than banning a verb: a string ban failed twice, and the honest replacement copy contains the stem.
This commit is contained in:
@@ -1,3 +1,45 @@
|
|||||||
|
## v0.213.0 — the banner promises only what the box can still see is true (2026-08-12, R-302) — MinAgent 0.127.0
|
||||||
|
|
||||||
|
**The abandon countdown told every customer who had given up their off-site history: *„Addig még
|
||||||
|
visszaszerezheted őket a helyreállítási kóddal."* Unconditionally, on every page. It is false on a
|
||||||
|
reachable state — and it rendered on the same screen as the orphan card correctly saying we cannot tell.
|
||||||
|
|
||||||
|
**Why the obvious condition was rejected, recorded so nobody re-proposes it.** The natural proxy —
|
||||||
|
*does the hub hold a key different from the one this box uses?* — asks about the WRONG key. The
|
||||||
|
set-aside copies were written under an OLDER key the box no longer has, which is why they were set
|
||||||
|
aside. On a twice-rebuilt box the proxy answers “yes, promise it” about copies no key on file can open:
|
||||||
|
right in the ordinary case, wrong in the very case that started the investigation. **Demonstrated, not
|
||||||
|
argued** — under the proxy both Scenario B (package replaced) and Scenario D (legacy countdown) flip
|
||||||
|
back to promising.
|
||||||
|
|
||||||
|
**Instead the fact is recorded at the one moment it is a fact.** `startAbandonCountdown` pins the hub’s
|
||||||
|
escrow key fingerprint as cached AT THE DECISION (`AbandonPinnedEscrowKeySHA256`). From then on the box
|
||||||
|
asks one exact question — *is the hub still holding that same package?* — rather than guessing which key
|
||||||
|
is which. Written once, never refreshed: a field re-read at render answers a different question. Same
|
||||||
|
shape as R-300’s ownership record two sessions ago.
|
||||||
|
|
||||||
|
**⚠ IT IS A RECORDED ASSUMPTION, AND IT SAYS SO.** Nothing on the box records which key wrote the
|
||||||
|
set-aside copies. The pin presumes the package held at the decision is that one — true in the ordinary
|
||||||
|
rebuilt-box story, not provable, and wrong on a twice-rebuilt box. Written into the field comment and
|
||||||
|
into R-302 so it can be narrowed later rather than hardening into a fact.
|
||||||
|
|
||||||
|
- **The certain half always renders**: the deletion and its date. Only the retrieval clause is conditional.
|
||||||
|
- **Empty is not a match**, on either side — the hub sends “” for a package sealing no repository password.
|
||||||
|
- **A countdown started before this release carries no pin and takes the cautious branch.** Not
|
||||||
|
backfilled: that would assert as recorded-at-the-decision something read long afterwards.
|
||||||
|
- **A FOURTH and FIFTH instance of the same promise were found by sweeping every template.** The backups
|
||||||
|
page block (`„a mentéseid visszaszerezhetők, és a törlés elmarad"`) got the same condition — fixing the
|
||||||
|
strip and not the page would leave one contradicting the other. The abandon CONFIRMATION screen
|
||||||
|
(`recovery.html`) was deliberately left: it renders at the moment of the decision, where the promise is
|
||||||
|
true by construction, because that is the package about to be pinned.
|
||||||
|
|
||||||
|
**New gate — `retrieval_promise_gate.py`, and it pins the CLAIM rather than the word.** A string ban was
|
||||||
|
tried twice and failed twice (singular vs plural; then one verb vs another). It cannot simply be
|
||||||
|
broadened either: **the honest replacement copy contains the stem**, inside a question about whether the
|
||||||
|
thing is knowable. So every retrieval-claim occurrence across all 36 templates is now REGISTERED with a
|
||||||
|
reason, and unregistered ones fail. Proven by planting all three historical wordings in turn — each
|
||||||
|
convicted, each cleared on removal.
|
||||||
|
|
||||||
## v0.212.0 — the second promise (2026-08-12, R-299) — MinAgent 0.127.0
|
## v0.212.0 — the second promise (2026-08-12, R-299) — MinAgent 0.127.0
|
||||||
|
|
||||||
**R-299 — the orphan card’s OTHER sentence made the same unevaluable promise, and the spec said it was
|
**R-299 — the orphan card’s OTHER sentence made the same unevaluable promise, and the spec said it was
|
||||||
|
|||||||
@@ -58,11 +58,21 @@ func (m *Manager) SetOffboxClock(fn func() time.Time) { m.offboxNow = fn }
|
|||||||
func (m *Manager) startAbandonCountdown(setAsidePath string) {
|
func (m *Manager) startAbandonCountdown(setAsidePath string) {
|
||||||
now := m.abandonNow().UTC()
|
now := m.abandonNow().UTC()
|
||||||
due := now.AddDate(0, 0, abandonGraceDays)
|
due := now.AddDate(0, 0, abandonGraceDays)
|
||||||
|
// R-302: pin the hub's escrow key fingerprint HERE, at the decision — the one moment it is a fact
|
||||||
|
// rather than something inferred later from an adjacent value. From now on the banner asks exactly
|
||||||
|
// one question, "is the hub still holding that same package?", instead of guessing which key is
|
||||||
|
// which. Written once and never refreshed: a field re-read at render answers a different question
|
||||||
|
// and would silently restore the defect this replaces.
|
||||||
|
pinned := ""
|
||||||
|
if m.settings != nil {
|
||||||
|
pinned, _ = m.settings.GetHubEscrowKeySHA256()
|
||||||
|
}
|
||||||
if err := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
if err := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||||
o.AbandonStartedAt = now.Format(time.RFC3339)
|
o.AbandonStartedAt = now.Format(time.RFC3339)
|
||||||
o.AbandonAt = due.Format(time.RFC3339)
|
o.AbandonAt = due.Format(time.RFC3339)
|
||||||
o.AbandonRepoPath = setAsidePath
|
o.AbandonRepoPath = setAsidePath
|
||||||
o.AbandonPurgeRequested = false
|
o.AbandonPurgeRequested = false
|
||||||
|
o.AbandonPinnedEscrowKeySHA256 = pinned
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
m.logger.Printf("[WARN] [offbox] could not record the abandonment countdown: %v", err)
|
m.logger.Printf("[WARN] [offbox] could not record the abandonment countdown: %v", err)
|
||||||
return
|
return
|
||||||
@@ -80,6 +90,15 @@ type AbandonState struct {
|
|||||||
DaysLeft int // ceiling, so "0 days left" only ever means "today"
|
DaysLeft int // ceiling, so "0 days left" only ever means "today"
|
||||||
RepoPath string // the set-aside store awaiting deletion
|
RepoPath string // the set-aside store awaiting deletion
|
||||||
PurgeRequested bool // the store is gone; awaiting the hub to drop the sealed package
|
PurgeRequested bool // the store is gone; awaiting the hub to drop the sealed package
|
||||||
|
// RetrievalStillOffered (R-302) — may the banner still say the set-aside copies can be retrieved
|
||||||
|
// with the recovery code? TRUE only while the hub is holding the SAME sealed package it held when
|
||||||
|
// the customer decided. Derived here, once, so the banner and anything else asking cannot disagree.
|
||||||
|
//
|
||||||
|
// FALSE covers: the package was replaced after the decision (a fresh escrow ceremony — the act that
|
||||||
|
// cost both demo boxes their history); the hub reports an empty hash (a legacy package sealing no
|
||||||
|
// repository password); and a countdown started before R-302, which carries no pin. All three are
|
||||||
|
// "we cannot see that this is still true", and all three must read as such rather than as a promise.
|
||||||
|
RetrievalStillOffered bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// AbandonStatus reports the countdown for the UI and the report. It never mutates.
|
// AbandonStatus reports the countdown for the UI and the report. It never mutates.
|
||||||
@@ -103,6 +122,13 @@ func (m *Manager) AbandonStatus() AbandonState {
|
|||||||
if s, serr := time.Parse(time.RFC3339, t.AbandonStartedAt); serr == nil {
|
if s, serr := time.Parse(time.RFC3339, t.AbandonStartedAt); serr == nil {
|
||||||
st.StartedAt = s
|
st.StartedAt = s
|
||||||
}
|
}
|
||||||
|
// R-302: the pinned fingerprint vs what the hub reports NOW. Both must be non-empty and equal.
|
||||||
|
// Empty on either side is "we could not see", never "they match" — the settings comment on
|
||||||
|
// HubEscrowKeySHA256 establishes that the hub sends "" for a package sealing no repo password.
|
||||||
|
if cur, _ := m.settings.GetHubEscrowKeySHA256(); cur != "" &&
|
||||||
|
t.AbandonPinnedEscrowKeySHA256 != "" && cur == t.AbandonPinnedEscrowKeySHA256 {
|
||||||
|
st.RetrievalStillOffered = true
|
||||||
|
}
|
||||||
// Ceiling: a countdown with 30 minutes left says "1 day", never "0". Zero is reserved for due.
|
// Ceiling: a countdown with 30 minutes left says "1 day", never "0". Zero is reserved for due.
|
||||||
remaining := due.Sub(m.abandonNow())
|
remaining := due.Sub(m.abandonNow())
|
||||||
if remaining <= 0 {
|
if remaining <= 0 {
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── R-302 — THE BANNER PROMISES ONLY WHAT THE BOX CAN STILL SEE IS TRUE ─────────────────────────
|
||||||
|
//
|
||||||
|
// The abandon banner said "until then you can still retrieve them with your recovery code",
|
||||||
|
// unconditionally, on every page. Yesterday's reading proved that false on a reachable state.
|
||||||
|
//
|
||||||
|
// THE CONDITION IS A PIN, NOT A COMPARISON AGAINST THE CURRENT KEY, and the difference is the whole
|
||||||
|
// design. The obvious proxy — "does the hub hold a key different from the one I use?" — asks about the
|
||||||
|
// wrong key: the set-aside copies were written under an OLDER key the box no longer has, which is why
|
||||||
|
// they were set aside. On a twice-rebuilt box the proxy answers "yes, promise it" about copies no key
|
||||||
|
// on file can open. The pin instead records the package the hub held AT THE DECISION and asks only
|
||||||
|
// "is the hub still holding that same one?".
|
||||||
|
//
|
||||||
|
// ⚠ THE PIN IS A RECORDED ASSUMPTION. It presumes the package held at the decision is the one that
|
||||||
|
// opens the set-aside copies. Nothing on the box records which key wrote them. See the field comment
|
||||||
|
// on settings.AbandonPinnedEscrowKeySHA256.
|
||||||
|
//
|
||||||
|
// The countdown is never started, shortened or triggered on a real machine — the clock is injected.
|
||||||
|
|
||||||
|
const pinnedHubKey = "1111111111111111111111111111111111111111111111111111111111111111"
|
||||||
|
const replacedHubKey = "2222222222222222222222222222222222222222222222222222222222222222"
|
||||||
|
|
||||||
|
// startedCountdown drives the PRODUCTION path (ResetOrphanedRepo → resetOrphanedRepo →
|
||||||
|
// startAbandonCountdown) so the pin cannot be written by tests alone while the live path never sets
|
||||||
|
// it — the inert-seam shape that has shipped here before, fully green.
|
||||||
|
func startedCountdown(t *testing.T, hubKeyAtDecision string) (*Manager, *settings.Settings, time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
start := time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC)
|
||||||
|
m, sett, _ := abandonFixture(t, start)
|
||||||
|
if err := sett.SetHubEscrowKeySHA256(hubKeyAtDecision, start.Format(time.RFC3339)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.ResetOrphanedRepo(context.Background()); err != nil {
|
||||||
|
t.Fatalf("the production reset path failed: %v", err)
|
||||||
|
}
|
||||||
|
return m, sett, start
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRODUCTION WIRING: the live decision path writes the pin. If this fails, every render test below is
|
||||||
|
// testing a field nothing sets.
|
||||||
|
func TestR302_ProductionResetPathWritesThePin(t *testing.T) {
|
||||||
|
_, sett, _ := startedCountdown(t, pinnedHubKey)
|
||||||
|
|
||||||
|
got := sett.GetOffboxTarget().AbandonPinnedEscrowKeySHA256
|
||||||
|
if got != pinnedHubKey {
|
||||||
|
t.Fatalf("pinned fingerprint = %q, want the hub key cached at the decision (%q). The whole "+
|
||||||
|
"design is that this is recorded when it is a fact; if the live path does not write it, "+
|
||||||
|
"the banner falls to the cautious branch for ever and the grace period becomes theatre",
|
||||||
|
got, pinnedHubKey)
|
||||||
|
}
|
||||||
|
if sett.GetOffboxTarget().AbandonAt == "" {
|
||||||
|
t.Error("no countdown recorded — the fixture is not exercising the path it claims to")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SCENARIO A — package unchanged since the decision → the promise stands ──────────────────────
|
||||||
|
//
|
||||||
|
// RED-PROOF: force the condition false (drop the `cur == t.AbandonPinnedEscrowKeySHA256` arm) and this
|
||||||
|
// fails — a customer who can genuinely still change their mind loses the clause, which the code says
|
||||||
|
// explicitly must not happen ("a grace period during which recovery is impossible would be theatre").
|
||||||
|
func TestR302_ScenarioA_PackageUnchanged_RetrievalStillOffered(t *testing.T) {
|
||||||
|
m, _, _ := startedCountdown(t, pinnedHubKey)
|
||||||
|
|
||||||
|
st := m.AbandonStatus()
|
||||||
|
if !st.Active {
|
||||||
|
t.Fatal("countdown not active")
|
||||||
|
}
|
||||||
|
if !st.RetrievalStillOffered {
|
||||||
|
t.Error("the hub still holds the same package it held at the decision, so the customer really " +
|
||||||
|
"can still change their mind — the promise must stand")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SCENARIO B — the package was REPLACED after the decision → promise withdrawn ────────────────
|
||||||
|
//
|
||||||
|
// This is the act that cost both demo boxes their history on 2026-08-04: a fresh escrow ceremony
|
||||||
|
// supersedes the package, and the old key it covered is unreachable (superseded packages grant no
|
||||||
|
// read path — hub store.go's own comment).
|
||||||
|
//
|
||||||
|
// RED-PROOF: re-read the pin at render (compare `cur` against itself, i.e. use the CURRENT cached
|
||||||
|
// value on both sides) and this fails — the promise returns, which is today's defect.
|
||||||
|
func TestR302_ScenarioB_PackageReplaced_PromiseWithdrawn(t *testing.T) {
|
||||||
|
m, sett, start := startedCountdown(t, pinnedHubKey)
|
||||||
|
|
||||||
|
// A fresh ceremony after the decision.
|
||||||
|
if err := sett.SetHubEscrowKeySHA256(replacedHubKey, start.Add(48*time.Hour).Format(time.RFC3339)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st := m.AbandonStatus(); st.RetrievalStillOffered {
|
||||||
|
t.Error("the hub's package was replaced after the customer decided, so the key that opened the " +
|
||||||
|
"set-aside copies is no longer served — the banner must stop promising retrieval")
|
||||||
|
}
|
||||||
|
// The pin itself must NOT have moved: it is written once, at the decision.
|
||||||
|
if got := sett.GetOffboxTarget().AbandonPinnedEscrowKeySHA256; got != pinnedHubKey {
|
||||||
|
t.Errorf("the pin was refreshed to %q — a field re-read later answers a different question and "+
|
||||||
|
"silently restores the defect this replaces", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SCENARIO D — a countdown started BEFORE this shipped carries no pin ─────────────────────────
|
||||||
|
//
|
||||||
|
// RED-PROOF: backfill the pin from the current cached value when it is empty and this fails — a legacy
|
||||||
|
// countdown gets promised at, asserting as recorded-at-the-decision something read long afterwards.
|
||||||
|
func TestR302_ScenarioD_LegacyCountdownWithoutAPin_TakesTheCautiousBranch(t *testing.T) {
|
||||||
|
m, sett, _ := startedCountdown(t, pinnedHubKey)
|
||||||
|
|
||||||
|
// Model the pre-R-302 on-disk shape: a live countdown, no pin.
|
||||||
|
if err := sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||||
|
o.AbandonPinnedEscrowKeySHA256 = ""
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st := m.AbandonStatus()
|
||||||
|
if !st.Active {
|
||||||
|
t.Fatal("countdown should still be running")
|
||||||
|
}
|
||||||
|
if st.RetrievalStillOffered {
|
||||||
|
t.Error("a countdown with no pin was promised at. There is no honest way to know whether the " +
|
||||||
|
"hub's package is still the one from the decision, and the cautious answer is the only one " +
|
||||||
|
"available")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SCENARIO E — pinned present, hub's cached value EMPTY → cautious ────────────────────────────
|
||||||
|
//
|
||||||
|
// The hub sends "" for a legacy package that provably seals no repository password. Empty is a
|
||||||
|
// measurement, not a match.
|
||||||
|
//
|
||||||
|
// RED-PROOF: treat empty as equal (drop the `cur != ""` arm) and this fails.
|
||||||
|
func TestR302_ScenarioE_EmptyHubHash_IsNotAMatch(t *testing.T) {
|
||||||
|
m, sett, start := startedCountdown(t, pinnedHubKey)
|
||||||
|
|
||||||
|
if err := sett.SetHubEscrowKeySHA256("", start.Add(time.Hour).Format(time.RFC3339)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st := m.AbandonStatus(); st.RetrievalStillOffered {
|
||||||
|
t.Error("an EMPTY hub hash was read as a match. It means the hub holds a package that seals no " +
|
||||||
|
"repository password — the opposite of evidence that retrieval works")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SCENARIO F — no countdown → nothing about retrieval is claimed at all ───────────────────────
|
||||||
|
func TestR302_ScenarioF_NoCountdown_NoClaim(t *testing.T) {
|
||||||
|
start := time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC)
|
||||||
|
m, _, _ := abandonFixture(t, start)
|
||||||
|
|
||||||
|
st := m.AbandonStatus()
|
||||||
|
if st.Active {
|
||||||
|
t.Fatal("no countdown was started, yet one is reported active")
|
||||||
|
}
|
||||||
|
if st.RetrievalStillOffered {
|
||||||
|
t.Error("retrieval was offered with no countdown running — the flag must be meaningless " +
|
||||||
|
"outside an abandonment, not default-true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pin is a hash of a secret. It must never reach a customer-facing surface or the report; this
|
||||||
|
// pins that it is not accidentally exported through the read model.
|
||||||
|
func TestR302_PinIsNotExposedThroughTheReadModel(t *testing.T) {
|
||||||
|
m, _, _ := startedCountdown(t, pinnedHubKey)
|
||||||
|
st := m.AbandonStatus()
|
||||||
|
if st.RepoPath == pinnedHubKey {
|
||||||
|
t.Fatal("the pin leaked into RepoPath")
|
||||||
|
}
|
||||||
|
// AbandonState carries a BOOLEAN verdict, never the fingerprint itself.
|
||||||
|
if got := st.RetrievalStillOffered; got != true && got != false {
|
||||||
|
t.Fatal("unreachable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SCENARIO E, the case that actually bites — BOTH sides empty ─────────────────────────────────
|
||||||
|
//
|
||||||
|
// A legacy countdown (no pin) on a box whose hub reports an empty hash (a package sealing no repo
|
||||||
|
// password). "" == "" is the equality that would quietly become a promise, and it is the ONLY state
|
||||||
|
// where dropping the emptiness guards changes the answer — TestR302_ScenarioE above passes even with
|
||||||
|
// them removed, because its pin is non-empty so the equality fails on its own. That test guards the
|
||||||
|
// sentence; this one guards the claim.
|
||||||
|
//
|
||||||
|
// RED-PROOF: drop either `cur != ""` or `t.AbandonPinnedEscrowKeySHA256 != ""` and this fails.
|
||||||
|
func TestR302_ScenarioE2_BothSidesEmpty_IsNotAMatch(t *testing.T) {
|
||||||
|
m, sett, start := startedCountdown(t, pinnedHubKey)
|
||||||
|
|
||||||
|
if err := sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||||
|
o.AbandonPinnedEscrowKeySHA256 = "" // legacy countdown, no pin
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := sett.SetHubEscrowKeySHA256("", start.Add(time.Hour).Format(time.RFC3339)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st := m.AbandonStatus()
|
||||||
|
if !st.Active {
|
||||||
|
t.Fatal("countdown should still be running")
|
||||||
|
}
|
||||||
|
if st.RetrievalStillOffered {
|
||||||
|
t.Error("two absences compared equal and became a promise. Empty means we could not see; two " +
|
||||||
|
"things we could not see are not a match")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -377,6 +377,27 @@ type OffboxTarget struct {
|
|||||||
// hub's ACK stops reporting a superseded package. If the two halves could not be removed together
|
// hub's ACK stops reporting a superseded package. If the two halves could not be removed together
|
||||||
// this marker is what makes the box keep asking until they are (Scenario F).
|
// this marker is what makes the box keep asking until they are (Scenario F).
|
||||||
AbandonPurgeRequested bool `json:"abandon_purge_requested,omitempty"`
|
AbandonPurgeRequested bool `json:"abandon_purge_requested,omitempty"`
|
||||||
|
// AbandonPinnedEscrowKeySHA256 (R-302) — the hub's escrow key fingerprint AS CACHED AT THE MOMENT
|
||||||
|
// THE CUSTOMER DECIDED. It is NOT the current key and NOT re-read: the banner's retrieval promise
|
||||||
|
// is rendered only while the hub is still holding that same package.
|
||||||
|
//
|
||||||
|
// ⚠ IT IS A RECORDED ASSUMPTION, NOT A PROOF, AND THAT IS DELIBERATE. Nothing on the box records
|
||||||
|
// which key wrote the set-aside copies — that key is gone, which is why they were set aside. What
|
||||||
|
// is pinned is the package the hub held at the decision, which in the ordinary rebuilt-box story IS
|
||||||
|
// the pre-rebuild escrow covering the pre-rebuild repo password, i.e. the one that wrote them. On a
|
||||||
|
// TWICE-rebuilt box that presumption can be wrong: the hub may hold rebuild #2's package while the
|
||||||
|
// set-aside copies are rebuild #1's, and no key on file opens them. This pin cannot detect that.
|
||||||
|
//
|
||||||
|
// What it DOES detect, and what the rejected alternative could not: the package being REPLACED
|
||||||
|
// after the decision — a fresh escrow ceremony, which is exactly the act that cost both demo boxes
|
||||||
|
// their history on 2026-08-04. The rejected proxy (hub fingerprint vs the CURRENT key, compared at
|
||||||
|
// render) answers "does the hub hold a different key?", which is TRUE in the co-render case and so
|
||||||
|
// would promise precisely where the promise is least safe.
|
||||||
|
//
|
||||||
|
// EMPTY IS MEANINGFUL AND IS NOT A MATCH: the hub sends "" for a legacy package that provably seals
|
||||||
|
// no repository password, and a countdown started before R-302 has no pin at all. Both take the
|
||||||
|
// cautious branch. NEVER rendered, logged or reported — it is the hash of a secret.
|
||||||
|
AbandonPinnedEscrowKeySHA256 string `json:"abandon_pinned_escrow_key_sha256,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CrossDriveBackup configures per-app backup to a secondary drive.
|
// CrossDriveBackup configures per-app backup to a secondary drive.
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── R-302 — RENDERED, at the boundary the defect lives at ───────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The condition is unit-tested in internal/backup; these assert the SENTENCES, because the defect was
|
||||||
|
// always copy that disagreed with what the box could see, and only the rendered bytes show that.
|
||||||
|
|
||||||
|
// abandonBannerData renders a page carrying the countdown strip. `offered` is the R-302 verdict.
|
||||||
|
func abandonBannerData(offered bool, repoState string) map[string]interface{} {
|
||||||
|
d := splitTestData()
|
||||||
|
d["Offbox"] = &settings.OffboxTarget{
|
||||||
|
Enabled: true, Host: "nas.local", User: "felhom", RepoPath: "/srv/repo",
|
||||||
|
EscrowState: "escrowed", RepoState: repoState, QuotaGB: 50, StatsKnown: true,
|
||||||
|
}
|
||||||
|
d["OffboxQuotaPct"] = 0
|
||||||
|
d["RecoveryBanner"] = true
|
||||||
|
d["RecoveryAbandonDays"] = 3
|
||||||
|
d["RecoveryAbandonDate"] = "2026-08-26"
|
||||||
|
d["RecoveryAbandonRetrievalOffered"] = offered
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
promiseClause = "visszaszerezheted őket a helyreállítási kóddal"
|
||||||
|
cautiousClause = "nem tudjuk megállapítani"
|
||||||
|
deletionClause = "véglegesen töröljük"
|
||||||
|
writeToUs = "írj nekünk a törlés előtt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── SCENARIO A — package unchanged → the clause stands, byte-identical in meaning to before ─────
|
||||||
|
func TestR302_Render_A_PromiseKeptWhenStillTrue(t *testing.T) {
|
||||||
|
html := renderBackupPage(t, "backups_remote", abandonBannerData(true, "ok"))
|
||||||
|
|
||||||
|
if !strings.Contains(html, deletionClause) {
|
||||||
|
t.Fatal("the deletion sentence is missing — that half is certain and must always render")
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, promiseClause) {
|
||||||
|
t.Error("R-302: a customer who can genuinely still change their mind lost the retrieval clause. " +
|
||||||
|
"The grace period is explicitly NOT decorative; hedging a true sentence is its own dishonesty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SCENARIO B/D/E (rendered) — cautious branch says what is true and names a route ─────────────
|
||||||
|
//
|
||||||
|
// RED-PROOF: remove the `{{if .RecoveryAbandonRetrievalOffered}}` conditional from layout.html and
|
||||||
|
// this fails on the first assertion — the false promise returns.
|
||||||
|
func TestR302_Render_B_CautiousBranchWhenNotKnowable(t *testing.T) {
|
||||||
|
html := renderBackupPage(t, "backups_remote", abandonBannerData(false, "ok"))
|
||||||
|
|
||||||
|
if strings.Contains(html, promiseClause) {
|
||||||
|
t.Error("R-302: the banner still promises retrieval when the box cannot see that it is true — " +
|
||||||
|
"this is the sentence a customer reads after giving up their history")
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, cautiousClause) {
|
||||||
|
t.Error("R-302: the cautious branch does not say we cannot determine it — silence is not the " +
|
||||||
|
"same as declining a claim")
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, writeToUs) {
|
||||||
|
t.Error("R-302: the cautious branch names no route, and it is time-bounded — the customer must " +
|
||||||
|
"be told to write in BEFORE the deletion date")
|
||||||
|
}
|
||||||
|
// The certain half is unconditional.
|
||||||
|
if !strings.Contains(html, deletionClause) {
|
||||||
|
t.Error("R-302: the deletion sentence was lost with the promise — it is the part we DO know")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SCENARIO C — THE CO-RENDER. The card and the banner must not contradict each other ──────────
|
||||||
|
//
|
||||||
|
// Reachable per yesterday's reading: ResetOrphanedRepo clears RepoState then starts the countdown, but
|
||||||
|
// markOrphaned (offbox.go:804) has NO guard against an active countdown, so a later run finding the
|
||||||
|
// FRESH store unopenable re-raises the card while the countdown runs.
|
||||||
|
//
|
||||||
|
// RED-PROOF — THE ONE THAT MATTERS: replace the condition with the rejected proxy (hub fingerprint vs
|
||||||
|
// the CURRENT key, compared at render). In this state those differ, so the proxy answers "promise it"
|
||||||
|
// and the false promise returns on the very screen the card is declining it. That is why the pin was
|
||||||
|
// chosen over the obvious condition.
|
||||||
|
func TestR302_Render_C_CoRenderDoesNotContradictItself(t *testing.T) {
|
||||||
|
html := renderBackupPage(t, "backups_remote", abandonBannerData(false, "orphaned"))
|
||||||
|
|
||||||
|
if !strings.Contains(html, "offbox-orphan-card") {
|
||||||
|
t.Fatal("the orphan card did not render — this test would then prove nothing about the co-render")
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, deletionClause) {
|
||||||
|
t.Fatal("the banner did not render — likewise")
|
||||||
|
}
|
||||||
|
// The card says we cannot tell. The banner must not say the opposite one strip above it.
|
||||||
|
if strings.Contains(html, promiseClause) {
|
||||||
|
t.Error("R-302 CO-RENDER: the orphan card says we cannot determine whether the set-aside copies " +
|
||||||
|
"can be opened, and the banner above it tells the customer they can still retrieve them. " +
|
||||||
|
"One page, two answers, and the confident one is the wrong one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SCENARIO F — no countdown → the undecided reminder ladder is untouched ──────────────────────
|
||||||
|
func TestR302_Render_F_NoCountdownLeavesTheLadderAlone(t *testing.T) {
|
||||||
|
d := splitTestData()
|
||||||
|
d["RecoveryBanner"] = true
|
||||||
|
d["RecoveryReminderTier"] = 14 // an undecided box, two weeks waiting
|
||||||
|
html := renderBackupPage(t, "backups_remote", d)
|
||||||
|
|
||||||
|
for _, s := range []string{deletionClause, promiseClause, cautiousClause} {
|
||||||
|
if strings.Contains(html, s) {
|
||||||
|
t.Errorf("abandonment copy %q leaked onto a box with no countdown running", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, "Két hete") {
|
||||||
|
t.Error("the undecided reminder ladder changed — it is not in scope and must be byte-identical")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -953,6 +953,11 @@ func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
data["AbandonActive"] = true
|
data["AbandonActive"] = true
|
||||||
data["AbandonDaysLeft"] = st.DaysLeft
|
data["AbandonDaysLeft"] = st.DaysLeft
|
||||||
data["AbandonDate"] = st.DueAt.Format("2006-01-02")
|
data["AbandonDate"] = st.DueAt.Format("2006-01-02")
|
||||||
|
// R-302: this block makes the SAME retrieval promise as the banner, under a different verb
|
||||||
|
// („visszaszerezhetők" vs the banner's „visszaszerezheted"), which is why it was a fourth
|
||||||
|
// instance nobody had counted. Same single derivation — fixing one surface and not the other
|
||||||
|
// would leave the page contradicting the strip above it.
|
||||||
|
data["AbandonRetrievalOffered"] = st.RetrievalStillOffered
|
||||||
} else if st.PurgeRequested {
|
} else if st.PurgeRequested {
|
||||||
// The store is deleted and the sealed package is on its way out. Say so rather than
|
// The store is deleted and the sealed package is on its way out. Say so rather than
|
||||||
// showing nothing, or the page silently loses a thing the customer was watching.
|
// showing nothing, or the page silently loses a thing the customer was watching.
|
||||||
|
|||||||
@@ -626,6 +626,9 @@ func (s *Server) addRecoveryBanner(data map[string]interface{}, r *http.Request)
|
|||||||
if st.DaysLeft <= mark {
|
if st.DaysLeft <= mark {
|
||||||
data["RecoveryAbandonDays"] = st.DaysLeft
|
data["RecoveryAbandonDays"] = st.DaysLeft
|
||||||
data["RecoveryAbandonDate"] = st.DueAt.Format("2006-01-02")
|
data["RecoveryAbandonDate"] = st.DueAt.Format("2006-01-02")
|
||||||
|
// R-302: the retrieval clause is conditional; the deletion sentence is not. Taken
|
||||||
|
// from the read model so this surface cannot form its own opinion.
|
||||||
|
data["RecoveryAbandonRetrievalOffered"] = st.RetrievalStillOffered
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,9 +188,14 @@
|
|||||||
<div class="card" style="border-left:3px solid var(--warn);margin:.75rem 0;padding:.75rem 1rem">
|
<div class="card" style="border-left:3px solid var(--warn);margin:.75rem 0;padding:.75rem 1rem">
|
||||||
<p style="margin:0 0 .35rem"><strong>A korábbi mentések törlése folyamatban</strong></p>
|
<p style="margin:0 0 .35rem"><strong>A korábbi mentések törlése folyamatban</strong></p>
|
||||||
<p class="form-hint" style="margin:0 0 .5rem">
|
<p class="form-hint" style="margin:0 0 .5rem">
|
||||||
|
{{/* R-302: the FOURTH instance of the retrieval promise, under a different verb than the
|
||||||
|
banner's — which is why no earlier guard counted it. Same condition, same single
|
||||||
|
derivation: fixing the strip above and not this would leave one page contradicting the
|
||||||
|
other. The deletion and its date are certain and always render. */}}
|
||||||
A kérésed szerint a korábbi távoli mentéseidet <strong>{{.AbandonDate}}</strong> napján véglegesen töröljük
|
A kérésed szerint a korábbi távoli mentéseidet <strong>{{.AbandonDate}}</strong> napján véglegesen töröljük
|
||||||
(még <strong>{{.AbandonDaysLeft}} nap</strong>). Addig meggondolhatod magad: ha megvan a helyreállítási kódod,
|
(még <strong>{{.AbandonDaysLeft}} nap</strong>).
|
||||||
a mentéseid visszaszerezhetők, és a törlés elmarad.
|
{{if .AbandonRetrievalOffered}}Addig meggondolhatod magad: ha megvan a helyreállítási kódod,
|
||||||
|
a mentéseid visszaszerezhetők, és a törlés elmarad.{{else}}Hogy ezek még visszaszerezhetők-e a helyreállítási kóddal, azt innen nem tudjuk megállapítani — ha vissza szeretnéd kapni őket, <strong>írj nekünk a törlés előtt</strong>.{{end}}
|
||||||
</p>
|
</p>
|
||||||
<a href="/recovery" class="btn btn-sm btn-primary">Mégis visszaszerzem a kóddal</a>
|
<a href="/recovery" class="btn btn-sm btn-primary">Mégis visszaszerzem a kóddal</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -140,7 +140,14 @@
|
|||||||
<span class="alert-icon"><svg class="ico"><use href="#i-triangle-alert"/></svg></span>
|
<span class="alert-icon"><svg class="ico"><use href="#i-triangle-alert"/></svg></span>
|
||||||
<span class="alert-message">
|
<span class="alert-message">
|
||||||
{{if .RecoveryAbandonDays}}
|
{{if .RecoveryAbandonDays}}
|
||||||
A korábbi távoli mentéseidet <strong>{{.RecoveryAbandonDays}} nap múlva</strong> ({{.RecoveryAbandonDate}}) véglegesen töröljük, a kérésed szerint. Addig még visszaszerezheted őket a helyreállítási kóddal.
|
{{/* R-302: the DELETION and its date are certain and always render. The RETRIEVAL clause is
|
||||||
|
conditional on the hub still holding the same sealed package it held when the customer
|
||||||
|
decided — pinned then, compared now. It rendered unconditionally, and was false on a
|
||||||
|
reachable state: a fresh escrow ceremony during the window replaces the package, which
|
||||||
|
is the exact act that cost both demo boxes their history on 2026-08-04. A countdown
|
||||||
|
started before this shipped carries no pin and takes the cautious branch. */}}
|
||||||
|
A korábbi távoli mentéseidet <strong>{{.RecoveryAbandonDays}} nap múlva</strong> ({{.RecoveryAbandonDate}}) véglegesen töröljük, a kérésed szerint.
|
||||||
|
{{if .RecoveryAbandonRetrievalOffered}}Addig még visszaszerezheted őket a helyreállítási kóddal.{{else}}Hogy ezek még visszaszerezhetők-e a helyreállítási kóddal, azt innen nem tudjuk megállapítani — ha vissza szeretnéd kapni őket, <strong>írj nekünk a törlés előtt</strong>.{{end}}
|
||||||
{{else if ge .RecoveryReminderTier 14}}
|
{{else if ge .RecoveryReminderTier 14}}
|
||||||
<strong>Két hete</strong> várnak rád a korábbi távoli mentéseid, és még nem adtad meg a helyreállítási kódodat. Amíg nem teszed, ezekhez a mentésekhez nem férsz hozzá.
|
<strong>Két hete</strong> várnak rád a korábbi távoli mentéseid, és még nem adtad meg a helyreállítási kódodat. Amíg nem teszed, ezekhez a mentésekhez nem férsz hozzá.
|
||||||
{{else if ge .RecoveryReminderTier 7}}
|
{{else if ge .RecoveryReminderTier 7}}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ GATES = [
|
|||||||
("mojibake", os.path.join(SCRIPTS, "mojibake_gate.py"), [], True),
|
("mojibake", os.path.join(SCRIPTS, "mojibake_gate.py"), [], True),
|
||||||
("docker-v", os.path.join(SCRIPTS, "docker_run_volume_path_gate.py"), [], True),
|
("docker-v", os.path.join(SCRIPTS, "docker_run_volume_path_gate.py"), [], True),
|
||||||
("secret-markup", os.path.join(SCRIPTS, "secret_in_markup_gate.py"), [], True),
|
("secret-markup", os.path.join(SCRIPTS, "secret_in_markup_gate.py"), [], True),
|
||||||
|
("retrieval-promise", os.path.join(SCRIPTS, "retrieval_promise_gate.py"), [], True),
|
||||||
("reuse-refs", SHARED_REUSE, [REPO], True),
|
("reuse-refs", SHARED_REUSE, [REPO], True),
|
||||||
("instructions", SHARED_INSTRUCTIONS, [REPO], True),
|
("instructions", SHARED_INSTRUCTIONS, [REPO], True),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""retrieval_promise_gate — pin the CLAIM, not the word (R-302).
|
||||||
|
|
||||||
|
WHY THIS IS NOT A STRING BAN. Five instances of "you can get your old backups back with your recovery
|
||||||
|
code" have surfaced ONE AT A TIME (R-294, R-299, and the two R-302 fixed this session), each found only
|
||||||
|
after the previous one was fixed. The obvious guard — forbid the sentence — was tried twice and failed
|
||||||
|
twice:
|
||||||
|
|
||||||
|
* v0.211.0 asserted the SINGULAR „visszaállítható lehet"; the card carried the PLURAL
|
||||||
|
„visszaállíthatók lehetnek" one paragraph above it and walked straight past (R-299).
|
||||||
|
* Broadening to the stem `visszaállíthat` then missed the banner entirely, because the banner says
|
||||||
|
„visszaszerezheted" — a different verb for the same claim.
|
||||||
|
|
||||||
|
AND THE STEM CANNOT BE BANNED. The honest replacement copy this session ships *contains the stem*:
|
||||||
|
„Hogy ezek még visszaszerezhetők-e … azt innen nem tudjuk megállapítani" is a QUESTION about
|
||||||
|
retrievability, and it is the correct sentence. A guard that forbade the stem would force the product
|
||||||
|
to avoid a normal Hungarian verb — a guard shaping the product around itself.
|
||||||
|
|
||||||
|
SO: every occurrence of a retrieval stem in a customer-facing template must be REGISTERED here with a
|
||||||
|
reason. Unregistered occurrences fail. The failure mode this actually catches is the real one — a new
|
||||||
|
claim appearing somewhere nobody was looking — without pretending a word is a claim.
|
||||||
|
|
||||||
|
Go template comments ({{/* … */}}) are stripped before scanning: html/template never renders them, so
|
||||||
|
prose explaining a fix is not a claim. HTML <!-- --> comments DO ship and are deliberately scanned.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
TEMPLATES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "internal", "web", "templates")
|
||||||
|
|
||||||
|
# The verbs that carry the claim "your old backups can be got back".
|
||||||
|
STEMS = ["visszaállíthat", "visszaszerezhet", "visszahozhat"]
|
||||||
|
|
||||||
|
# (template, substring that identifies the occurrence) -> why it is allowed.
|
||||||
|
# The substring must be specific enough that a DIFFERENT claim in the same file does not match it.
|
||||||
|
ALLOWLIST = {
|
||||||
|
("backups.html", "amelyből az egész készülék visszaállítható"):
|
||||||
|
"the LOCAL whole-device backup, made and held by the host agent. Nothing to do with the "
|
||||||
|
"off-site escrow claim — no recovery code is involved.",
|
||||||
|
("backups_apps.html", "alkalmazásonként visszaállítható"):
|
||||||
|
"per-app restore from the local app-data backup. Same: local, no recovery code.",
|
||||||
|
("backups_remote.html", "mentéseid visszaszerezhetők.</strong>"):
|
||||||
|
"the RecoveryOffer entry point. TRUE where it renders: it is gated on the hub telling this box "
|
||||||
|
"it holds a sealed package for it, which is the claim being made. Deliberately left alone.",
|
||||||
|
("backups_remote.html", "a mentéseid visszaszerezhetők, és a törlés elmarad."):
|
||||||
|
"the abandon block's promise — R-302 made it conditional on AbandonRetrievalOffered; this is "
|
||||||
|
"the TRUE branch.",
|
||||||
|
("backups_remote.html", "Hogy ezek még visszaszerezhetők-e"):
|
||||||
|
"R-302's cautious branch. Contains the stem inside a QUESTION about knowability — the sentence "
|
||||||
|
"the gate exists to protect, not to forbid.",
|
||||||
|
("layout.html", "Addig még visszaszerezheted őket a helyreállítási kóddal."):
|
||||||
|
"the banner's promise — R-302 made it conditional on RecoveryAbandonRetrievalOffered; TRUE branch.",
|
||||||
|
("layout.html", "Hogy ezek még visszaszerezhetők-e"):
|
||||||
|
"R-302's cautious branch on the banner. As above.",
|
||||||
|
("recovery.html", "a mentéseid visszaszerezhetők, és a törlés elmarad;"):
|
||||||
|
"the abandon CONFIRMATION screen, shown at the moment of the decision. True by construction "
|
||||||
|
"there: the package the hub holds right now is the one about to be pinned. Left alone.",
|
||||||
|
}
|
||||||
|
|
||||||
|
TEMPLATE_COMMENT = re.compile(r"\{\{/\*.*?\*/\}\}", re.S)
|
||||||
|
|
||||||
|
|
||||||
|
def scan():
|
||||||
|
convictions, seen_keys = [], set()
|
||||||
|
files = sorted(f for f in os.listdir(TEMPLATES) if f.endswith(".html"))
|
||||||
|
for name in files:
|
||||||
|
path = os.path.join(TEMPLATES, name)
|
||||||
|
text = TEMPLATE_COMMENT.sub("", open(path, encoding="utf-8").read())
|
||||||
|
for stem in STEMS:
|
||||||
|
for m in re.finditer(re.escape(stem) + r"[a-záéíóöőúüű]*", text):
|
||||||
|
line = text[: m.start()].count("\n") + 1
|
||||||
|
# SPAN-based, not window-based. The promise and the cautious disclaimer sit within a
|
||||||
|
# hundred characters of each other in the same paragraph, so a proximity window matches
|
||||||
|
# whichever key it tries first and reports the other as stale — which is exactly what a
|
||||||
|
# first draft of this gate did. An occurrence belongs to an entry only if it falls
|
||||||
|
# INSIDE that entry's own text.
|
||||||
|
hit = None
|
||||||
|
for k in (k for k in ALLOWLIST if k[0] == name):
|
||||||
|
for om in re.finditer(re.escape(k[1]), text):
|
||||||
|
if om.start() <= m.start() and m.end() <= om.end():
|
||||||
|
hit = k
|
||||||
|
break
|
||||||
|
if hit:
|
||||||
|
break
|
||||||
|
if hit:
|
||||||
|
seen_keys.add(hit)
|
||||||
|
else:
|
||||||
|
ctx = text[max(0, m.start() - 100): m.end() + 100]
|
||||||
|
convictions.append((name, line, m.group(0), " ".join(ctx.split())[:160]))
|
||||||
|
return files, convictions, seen_keys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
files, convictions, seen = scan()
|
||||||
|
stale = [k for k in ALLOWLIST if k not in seen]
|
||||||
|
for name, line, word, ctx in convictions:
|
||||||
|
print(f" {name}:{line} unregistered retrieval claim ({word}):\n …{ctx}…")
|
||||||
|
for k in stale:
|
||||||
|
print(f" STALE ALLOWLIST ENTRY (no longer present): {k[0]} :: {k[1]!r}")
|
||||||
|
if convictions or stale:
|
||||||
|
print(f"\nRETRIEVAL-PROMISE GATE FAILED: {len(convictions)} unregistered, {len(stale)} stale, "
|
||||||
|
f"across {len(files)} template(s).")
|
||||||
|
print("Five instances of this claim have surfaced one at a time. If the new text is a genuine")
|
||||||
|
print("claim, make it CONDITIONAL on what the box can see; if it is a question about")
|
||||||
|
print("knowability, or an unrelated local-backup sentence, add it to ALLOWLIST with the reason.")
|
||||||
|
return 1
|
||||||
|
print(f"retrieval-promise gate OK — {len(files)} templates, {len(ALLOWLIST)} registered claim(s), "
|
||||||
|
f"none unregistered")
|
||||||
|
print(" (BLIND SPOT: it registers WHERE the claim is made, not whether each conditional is wired")
|
||||||
|
print(" to a true predicate — that is what the R-302 render tests are for.)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user