// Refusal records — D1 of the console release defences (spec // 2026-10-06-console-release-defences-design.md §2.5). // // When a named unit does not come up on a new build, the root pass rolls it // back and writes one of these per host in the rolled-back process group. // Every later pass, the agent's fast loop and root alike, reads it before // staging and skips the promotion it names. Without it the very next pass // would adopt the same build and bounce the service again. // // The record blocks a PROMOTION, not a version name: it matches while the // channel pointer still has the version and PromotedAt it had when the build // was refused. `carlos promote --retry` re-signs the pointer with a fresh // PromotedAt, and so does any move to another version; either clears it. // // Root writes these under the bake directory, which the sandboxed agent can // read. A file that does not parse reads as absent: a torn write must never // become a permanent block. package release import ( "encoding/json" "errors" "fmt" "log" "os" "path/filepath" "sort" "strings" "time" "github.com/carlosframework/platform/internal/safefile" ) const refusalSchema = 1 // What a refusal is for. RefusalNotUp (the zero value, so every record written // before Kind existed reads as one) is D1: the unit did not come up and was // rolled back. RefusalWrongProgram is D2: the staged binary is a different Go // program from any this route has run, so it was never pointed live and // nothing restarted. RefusalNotProven is D3 (proofgate.go): the staged bytes // are not the ones the proof covers, so they too were never pointed live. A // "--retry" cures the first and loops on the other two. const ( RefusalNotUp = "" RefusalWrongProgram = "wrong-program" RefusalNotProven = "not-proven" ) type Refusal struct { Schema int `json:"schema"` // Kind is RefusalNotUp, RefusalWrongProgram or RefusalNotProven. Omitted // when not-up. Kind string `json:"kind,omitempty"` Host string `json:"host"` App string `json:"app"` Version string `json:"version"` SHA256 string `json:"sha256,omitempty"` Platform string `json:"platform,omitempty"` PointerPromotedAt time.Time `json:"pointer_promoted_at"` RefusedAt time.Time `json:"refused_at"` Reason string `json:"reason"` // The rollback's outcome, recorded after its restart (spec §2.6: a // recovery that failed must not be published as a rollback). // RestoredVersion is what the bound host's link was put back on, "" when // that repoint itself failed or there was nothing to put back. Recovered // is true only when the link was rolled back AND the restart on it came // up; RecoveryError says why not otherwise. RestoredVersion string `json:"restored_version,omitempty"` Recovered bool `json:"recovered"` RecoveryError string `json:"recovery_error,omitempty"` // MoveOp is set only on a move's held-stage refusal (materializeheld.go, // ruling D3-held): the op of the move whose held stage was refused. It // scopes the record to that move, which retires it (a later held stage // of the move that succeeds, or the move ending), and it is never set on // an adoption refusal, so adoption's records are byte-identical. Held // records live under HeldRefusalBase, never in RefusalDir(bakeDir). MoveOp string `json:"move_op,omitempty"` } func RefusalDir(bakeDir string) string { return filepath.Join(bakeDir, "refusals") } func refusalPath(bakeDir, host string) (string, error) { if host == "" || strings.ContainsAny(host, `/\`) || host == "." || host == ".." { return "", fmt.Errorf("refusal: invalid host %q", host) } return filepath.Join(RefusalDir(bakeDir), host+".json"), nil } // WriteRefusal writes atomically (temp file + rename) and world-readable, // because the reader is the sandboxed agent. func WriteRefusal(bakeDir string, r Refusal) error { p, err := refusalPath(bakeDir, r.Host) if err != nil { return err } r.Schema = refusalSchema b, err := json.MarshalIndent(r, "", " ") if err != nil { return err } if err := safefile.MkdirAll(filepath.Dir(p), 0o755); err != nil { return err } return safefile.WriteFile(p, append(b, '\n'), 0o644) } func ReadRefusal(bakeDir, host string) (*Refusal, error) { p, err := refusalPath(bakeDir, host) if err != nil { return nil, err } b, err := safefile.ReadFile(p) if errors.Is(err, os.ErrNotExist) { return nil, nil } if err != nil { return nil, err } var r Refusal if err := json.Unmarshal(b, &r); err != nil || r.Schema != refusalSchema { log.Printf("refusal: %s does not parse (%v), treating it as absent", p, err) return nil, nil } return &r, nil } func ListRefusals(bakeDir string) ([]Refusal, error) { ents, err := os.ReadDir(RefusalDir(bakeDir)) if errors.Is(err, os.ErrNotExist) { return nil, nil } if err != nil { return nil, err } var out []Refusal for _, e := range ents { name := e.Name() if e.IsDir() || !strings.HasSuffix(name, ".json") || strings.HasPrefix(name, ".") { continue } r, err := ReadRefusal(bakeDir, strings.TrimSuffix(name, ".json")) if err != nil || r == nil { continue } out = append(out, *r) } sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host }) return out, nil } func ClearRefusal(bakeDir, host string) error { p, err := refusalPath(bakeDir, host) if err != nil { return err } if err := safefile.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) { return err } return nil } // Blocks reports whether this record still refuses the promotion p names. func (r *Refusal) Blocks(p *ChannelPointer) bool { return r != nil && p != nil && r.Version == p.Version && r.PointerPromotedAt.Equal(p.PromotedAt) }