R-304 option C: operator mail when a household's code opens or may open an older package (once a day); R-298 side fix (no eject/format on a backup-target drive); R-717 comments
gates / gates (push) Successful in 59s

Unreleased; ships with tomorrow's release (needs the hub of the same day).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-08 10:07:03 +02:00
parent a0370b4ed8
commit a40729a698
10 changed files with 186 additions and 3 deletions
+14 -1
View File
@@ -1,4 +1,4 @@
## Unreleased (2026-10-08) — a daytime press never cancels the night's whole-guest backup (R-899; operator ruling 2026-10-08, option A); an honest answer when older recovery packages were not checked (R-304) — ships with tomorrow's release
## Unreleased (2026-10-08) — a daytime press never cancels the night's whole-guest backup (R-899; operator ruling 2026-10-08, option A); an honest answer when older recovery packages were not checked, and the operator's mail (R-304) — ships with tomorrow's release
**MinAgent: 0.131.0** (unchanged — the new `trigger=manual` query is ignored by an older agent, which keeps running the
OS leg after a press exactly as before; the night itself is fixed by the controller alone).
@@ -20,6 +20,19 @@ OS leg after a press exactly as before; the night itself is fixed by the control
Hungarian and English. Not version-gated on purpose (it accuses nobody; a stray 424 lands on a neutral sentence).
Tests `TestR304_OlderUnchecked_IsNotAWrongCode` (hu + en; red-proved: without the case the page fell to the „nem tudjuk
biztosan, miért" default) and `TestR304_Classify424`.
- **R-304 option C (operator ruling 2026-10-08 09:04, `09` §3 decision 183):** when a household's recovery code OPENS an
older sealed package (agent 422) or MAY open one (424), the controller sends the operator-only event
`recovery_older_package` (warning → the operator's mail): the class and the package's date, never the code. At most
once per day per box (`<data>/recovery-older-mail.day`, survives a restart). **Needs the hub release of the same day**
(the type is in its allowlist and `operatorOnlyEvents` there); against an older hub the event is refused (400) and only
logged. `internal/web/recovery_older_mail.go`; tests `TestR304_OlderPackageMail_*` (red-proved twice: without the calls
`events = []`; without the day check `sent 2 events, want still 1`).
- **R-298 side finding:** the storage page no longer offers „Leválasztás" or „Formázás" on a user-data drive that is ALSO
the whole-system backup target (agent `backup_target`): the agent refuses its eject (403) and a data-bearing format needs
a signed job. JS in `templates/storage.html` `actions()`; the two storage parity fixtures regenerated (only these lines
differ). Not verified in a browser.
- R-717: two stale comments corrected (`after_setup.go` no longer names opengist among the `command` apps;
`signup_block.go` shows opengist's current `PathRegexp` block).
- The press now reaches the agent as `POST /backup?…trigger=manual` (`agentapi.StartBackupForTrigger`); an agent that
knows it runs no OS leg after a press (agent, same day). Test `TestR899_PressCarriesTriggerManual` asserts the query the
agent receives for each of the four shapes.
+2 -1
View File
@@ -30,7 +30,8 @@ import (
//
// A command form (`service`, `command`, `success`, `env` names) follows after_install's argv-safe rules (R-713).
//
// R-717: an app whose switch lives in its own database (opengist, wishlist) is closed by `command` and reopened for
// R-717: an app whose switch lives in its own database (today: wishlist; opengist is closed by its signup_block,
// signup_block.go) is closed by `command` and reopened for
// the household's window by its twin:
//
// after_setup:
+1 -1
View File
@@ -19,7 +19,7 @@ import (
// without a gate. The household lets a family member join by opening sign-up for 15 minutes from the app page
// (OpenSignupWindow); the loop closes it again. Decided by CC unattended 2026-09-29 — the operator may reverse.
//
// signup_block: "PathPrefix(`/-/register`)" # opengist
// signup_block: "PathRegexp(`(?i)^/+-/+register`)" # opengist (catalog templates/opengist/.felhom.yml)
//
// Only an app whose setup gate this box opened carries a block: an app installed before (no gate record) is never
// touched — the same rule as the gate itself (Part 0, 2026-09-29).
@@ -444,6 +444,7 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) {
when = " (" + at + ")"
}
s.logger.Printf("[INFO] [web] recovery: the code opened a RETAINED package — the customer is not at fault")
s.notifyRecoveryOlderPackage(class, func() string { _, at := s.recoverySuperseded(); return at }())
s.renderRecovery(w, r, "A kódod helyes, de egy korábbi csomagot nyit meg, nem azt, amit most őrzünk ehhez a géphez. A géped időközben új mentési kulcsot kapott. A korábbi csomagot"+when+" nem töröltük, megőrizzük — a mostani mentéseidet ez nem érinti, azokkal semmi nem történt. A régebbi előzményed visszanyitásához a Felhom ügyfélszolgálatának segítsége kell: írj nekik, és add meg, hogy a régi mentéseidhez szeretnél hozzáférni. A kódodat tedd el, szükség lesz rá.", "", nil)
return
case agentapi.RecoveryOlderUnchecked:
@@ -455,6 +456,7 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) {
// possibility (R-226: a new-code holder may be typing), and names the route. Pinned by
// TestR304_OlderUnchecked_IsNotAWrongCode.
s.logger.Printf("[WARN] [web] recovery: earlier sealed packages were not all checked — not reported as a wrong code (R-304)")
s.notifyRecoveryOlderPackage(class, func() string { _, at := s.recoverySuperseded(); return at }())
s.renderRecovery(w, r, s.msg(r, "recovery.older_unchecked"), "", nil)
return
case agentapi.RecoveryAskedAndRefused:
@@ -0,0 +1,82 @@
package web
import (
"os"
"path/filepath"
"strings"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
// R-304 option C (operator ruling 2026-10-08 09:04, `09` §3 decision 183): when a household's recovery code OPENS an
// older sealed package (the agent's 422), or MAY open one (424 — not every older package was tried), the OPERATOR gets
// one mail: retention is an operator-only capability (R-312), so „contact support" on the screen is only true in
// practice if the operator already knows. Never the code, never a password — the class and the package's date only.
//
// At most ONCE PER DAY per box: the day (UTC, YYYY-MM-DD) of the last send is kept in `<data>/recovery-older-mail.day`,
// so a controller restart does not mail twice. A household retyping its code ten times sends one mail.
// The event type `recovery_older_package` is operator-only at the hub (`notify.operatorOnlyEvents`, same day's hub).
// Pinned by TestR304_OlderPackageMail_* (recovery_older_mail_test.go).
const recoveryOlderMailFile = "recovery-older-mail.day"
// recoveryOlderPush sends the event; recoveryOlderPushFn is the test seam (nil → the notifier).
// The severity is a literal on purpose: the hub's severity vocabulary is checked statically
// (TestR329_EveryEmittedSeverityIsInTheHubVocabulary).
func (s *Server) recoveryOlderPush(message string, details map[string]string) {
if s.recoveryOlderPushFn != nil {
s.recoveryOlderPushFn("recovery_older_package", "warning", message, details)
return
}
if s.notifier != nil {
s.notifier.PushEvent("recovery_older_package", "warning", message, details)
}
}
func (s *Server) recoveryOlderMailPath() string {
if s.cfg == nil || s.cfg.Paths.DataDir == "" {
return ""
}
return filepath.Join(s.cfg.Paths.DataDir, recoveryOlderMailFile)
}
// notifyRecoveryOlderPackage sends the operator mail for a 422/424 unlock, at most once per day.
func (s *Server) notifyRecoveryOlderPackage(class agentapi.RecoveryFailure, supersededAt string) {
today := s.recoveryNow().UTC().Format("2006-01-02")
p := s.recoveryOlderMailPath()
s.recoveryOlderMu.Lock()
defer s.recoveryOlderMu.Unlock()
last := s.recoveryOlderLastDay
if p != "" {
if b, err := os.ReadFile(p); err == nil {
last = strings.TrimSpace(string(b))
}
}
if last == today {
s.logger.Printf("[INFO] [web] recovery: older-package operator mail already sent today — not again (R-304)")
return
}
when := supersededAt
if when == "" {
when = "unknown"
}
var msg string
switch class {
case agentapi.RecoveryCodeOpensRetained:
msg = "A household's recovery code OPENED an older sealed escrow package (superseded " + when + ") — not the current one. " +
"Their old off-site history may be what they need; reopening it is operator-only (R-312). The screen told them to contact support."
default:
msg = "A household's recovery code did not open the current sealed escrow package, and NOT every older package was tried " +
"(newest older package superseded " + when + ") — the code may belong to one of them. The screen told them to contact support."
}
s.recoveryOlderPush(msg, map[string]string{
"class": class.String(), "superseded_at": supersededAt,
})
s.recoveryOlderLastDay = today
if p != "" {
if err := os.WriteFile(p, []byte(today+"\n"), 0o600); err != nil {
s.logger.Printf("[WARN] [web] recovery: could not record today's older-package mail (%v) — a restart may send one more today", err)
}
}
s.logger.Printf("[INFO] [web] recovery: operator told — the code %s an older package (R-304)", map[bool]string{true: "opens", false: "may open"}[class == agentapi.RecoveryCodeOpensRetained])
}
@@ -0,0 +1,66 @@
package web
import (
"net/http"
"strings"
"testing"
"time"
)
// R-304 option C (decision 183): the operator is mailed when a household's code opens (422) or may open (424) an older
// sealed package — once per day per box, never on a wrong code, and never with the code in it.
// RED-PROOF: remove the two notifyRecoveryOlderPackage calls from recoveryUnlockHandler → no event → the first
// assertion FAILS; drop the day check → the second unlock sends a second event → FAILS.
type olderEvent struct{ typ, sev, msg, class string }
func olderFixture(t *testing.T, now *time.Time) (*recoveryFixture, *[]olderEvent) {
t.Helper()
f := newRecoveryFixture(t)
var evs []olderEvent
f.s.recoveryOlderPushFn = func(typ, sev, msg string, d map[string]string) {
evs = append(evs, olderEvent{typ, sev, msg, d["class"]})
}
f.s.SetRecoveryClock(func() time.Time { return *now })
return f, &evs
}
func TestR304_OlderPackageMail_OncePerDay(t *testing.T) {
now := time.Date(2026, 10, 8, 10, 0, 0, 0, time.UTC)
f, evs := olderFixture(t, &now)
f.rec.failWith = refusal(422, "the recovery code is correct, but it belongs to an EARLIER sealed package")
postUnlockWith(t, f.s, testRecoveryCode)
if len(*evs) != 1 || (*evs)[0].typ != "recovery_older_package" || (*evs)[0].sev != "warning" || (*evs)[0].class != "code-opens-retained" {
t.Fatalf("events = %+v, want one warning recovery_older_package (code-opens-retained)", *evs)
}
if strings.Contains((*evs)[0].msg, testRecoveryCode) {
t.Fatal("the operator mail must never carry the recovery code")
}
now = now.Add(3 * time.Hour)
postUnlockWith(t, f.s, testRecoveryCode)
if len(*evs) != 1 {
t.Fatalf("a second try the same day sent %d events, want still 1", len(*evs))
}
// A controller restart the same day: a new Server over the same data dir must not mail again.
f.s.recoveryOlderLastDay = ""
postUnlockWith(t, f.s, testRecoveryCode)
if len(*evs) != 1 {
t.Fatalf("after a restart the same day: %d events, want still 1 (the day is on disk)", len(*evs))
}
now = now.Add(24 * time.Hour)
f.rec.failWith = refusal(http.StatusFailedDependency, "not all checked")
postUnlockWith(t, f.s, testRecoveryCode)
if len(*evs) != 2 || (*evs)[1].class != "older-unchecked" {
t.Fatalf("next day, a 424: events = %+v, want a second one (older-unchecked)", *evs)
}
}
func TestR304_OlderPackageMail_NotOnAWrongCode(t *testing.T) {
now := time.Date(2026, 10, 8, 10, 0, 0, 0, time.UTC)
f, evs := olderFixture(t, &now)
f.rec.failWith = refusal(400, "the recovery code did not open the sealed bundle")
postUnlockWith(t, f.s, testRecoveryCode)
if len(*evs) != 0 {
t.Fatalf("a plain wrong code must not mail the operator: %+v", *evs)
}
}
+4
View File
@@ -154,6 +154,10 @@ type Server struct {
// recoveryRetainedTrustedFn overrides the R-311 gate deciding whether a 422 may be read as "the
// code is correct and opens a RETAINED earlier package" (tests). INIT-ONLY.
recoveryRetainedTrustedFn func(context.Context) bool
// R-304 option C: the operator's older-package mail — the test seam, and the once-per-day record (recovery_older_mail.go).
recoveryOlderPushFn func(eventType, severity, message string, details map[string]string)
recoveryOlderMu sync.Mutex
recoveryOlderLastDay string
// recoveryNowFn is the unlock path's clock (tests inject; nil → time.Now). Observability and
// tests only — never a classifier.
recoveryNowFn func() time.Time
@@ -302,6 +302,11 @@ window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}}
function actions(d, registered, hasSafeDisconnect){
// Destructive controls ONLY for user-data drives that are mounted under /mnt. System/backup get none.
if(d.role!=='user-data' || !d.mount_path || d.mount_path.indexOf('/mnt/')!==0) return '';
// A user-data drive that ALSO backs the whole-system backup (agent `backup_target`): the agent refuses its eject
// (403) and a data-bearing format needs a signed job, so neither button is offered (R-298 design, 2026-10-08).
if(d.backup_target){
return registered[regKey(d)] ? '' : '<div class="drive-actions"><button class="btn btn-xs btn-primary" onclick="registerDrive(\''+esc(d.mount_path)+'\')">{{T "storage.regisztralas"}}</button></div>';
}
var dev = esc(d.backing_device||''), mpRaw = esc(d.mount_path), reg = esc(regKey(d));
var btns = '';
// Two distinct path arguments (the intermediary model):
@@ -314,6 +314,11 @@ window.__registeredPaths=[];
function actions(d, registered, hasSafeDisconnect){
if(d.role!=='user-data' || !d.mount_path || d.mount_path.indexOf('/mnt/')!==0) return '';
if(d.backup_target){
return registered[regKey(d)] ? '' : '<div class="drive-actions"><button class="btn btn-xs btn-primary" onclick="registerDrive(\''+esc(d.mount_path)+'\')">Regisztrálás</button></div>';
}
var dev = esc(d.backing_device||''), mpRaw = esc(d.mount_path), reg = esc(regKey(d));
var btns = '';
@@ -614,6 +614,11 @@ window.__registeredPaths=["\/mnt\/usb1","\/mnt\/old","\/mnt\/hdd1","\/mnt\/hdd2"
function actions(d, registered, hasSafeDisconnect){
if(d.role!=='user-data' || !d.mount_path || d.mount_path.indexOf('/mnt/')!==0) return '';
if(d.backup_target){
return registered[regKey(d)] ? '' : '<div class="drive-actions"><button class="btn btn-xs btn-primary" onclick="registerDrive(\''+esc(d.mount_path)+'\')">Regisztrálás</button></div>';
}
var dev = esc(d.backing_device||''), mpRaw = esc(d.mount_path), reg = esc(regKey(d));
var btns = '';