catalog: app lifecycle states - available/hidden/abandoned (v0.158.0)

The catalog knew only 'present' or 'gone', and 'gone' orphans every customer
already running the app. lifecycle: in .felhom.yml withdraws an app from new
installs without touching anyone running it.

Deploy gate is server-side and fail-closed, before any mutation, with the
ruled Hungarian refusal - hiding a button is not a gate. Unknown values fail
OPEN (available + one WARN), deliberately opposite, so a typo or a newer
catalog cannot pull a working app out of every customer's list.

Orphan detection never sees the field - a red-proof adds that filter and shows
the abandoned app immediately reading as an orphan.

Badge plumbing is generic (MetaBadge + meta_badge partial) so R-56's
difficulty labels drop in with no new markup.
This commit is contained in:
2026-07-21 16:19:58 +02:00
parent a96226a8ae
commit ea0d3f1764
6 changed files with 437 additions and 0 deletions
@@ -0,0 +1,97 @@
package api
import (
"go/ast"
"go/parser"
"go/token"
"strings"
"testing"
)
// TestDeployStackWiresTheLifecycleGate — the seam-discipline test (§9 rule 6).
//
// The lifecycle predicate is unit-tested in internal/stacks, and a test there passes whether or not
// deployStack ever calls it. Three inert-seam defects shipped fully-green in three days (controller
// v0.154.0, agent v0.91.0, agent v0.92.0's missing sudoers grant), all this exact shape: correct
// component, absent caller. So the CALLER is asserted here, from source.
//
// It walks the AST rather than doing strings.Contains on the file, because a commented-out call
// still contains the string — the lesson recorded in PROMPT-TEMPLATE §10.
//
// It also asserts ORDER: the gate must precede the DeployStack call, or it is not fail-closed.
func TestDeployStackWiresTheLifecycleGate(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "router.go", nil, 0) // comments dropped: a commented call is not a call
if err != nil {
t.Fatalf("parse router.go: %v", err)
}
var fn *ast.FuncDecl
ast.Inspect(f, func(n ast.Node) bool {
if d, ok := n.(*ast.FuncDecl); ok && d.Name.Name == "deployStack" {
fn = d
return false
}
return true
})
if fn == nil {
t.Fatal("deployStack not found in router.go — did it move? the gate's wiring is now unasserted")
}
canInstallPos, deployPos := -1, -1
ast.Inspect(fn, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
off := fset.Position(call.Pos()).Offset
switch sel.Sel.Name {
case "CanInstall":
if canInstallPos == -1 {
canInstallPos = off
}
case "DeployStack":
if deployPos == -1 {
deployPos = off
}
}
return true
})
if canInstallPos == -1 {
t.Fatal("deployStack never calls Meta.CanInstall() — the lifecycle gate is INERT: " +
"a withdrawn app is hidden from the catalog page but still installable by direct POST")
}
if deployPos == -1 {
t.Fatal("deployStack no longer calls DeployStack — this test's ordering assertion is meaningless")
}
if canInstallPos > deployPos {
t.Fatalf("the lifecycle gate (offset %d) runs AFTER DeployStack (offset %d) — a gate that "+
"fires after the mutation is not fail-closed", canInstallPos, deployPos)
}
}
// TestLifecycleRefusalMessageIsCustomerFacingHungarian: the refusal text reaches the customer via
// showAlert(), so it must be the sentence the spec ruled, not a Go error string.
func TestLifecycleRefusalMessageIsCustomerFacingHungarian(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "router.go", nil, 0)
if err != nil {
t.Fatal(err)
}
const want = "Ez az alkalmazás jelenleg nem telepíthető."
found := false
ast.Inspect(f, func(n ast.Node) bool {
if lit, ok := n.(*ast.BasicLit); ok && lit.Kind == token.STRING && strings.Contains(lit.Value, want) {
found = true
}
return true
})
if !found {
t.Fatalf("the ruled refusal message %q is not present in router.go", want)
}
}