package hostactors import ( "context" "encoding/json" "errors" "github.com/carlosframework/platform/internal/termfence" "os" "time" ) // TerminationFence reads authenticated worker-owned, create-only evidence. // Object access failures and invalid bindings are errors, never exclusions. func (d Directory) TerminationFence(ctx context.Context, o Observed, ref string, now time.Time) (bool, error) { b, ok := o.Roster.Boxes[ref] if !ok || !token.MatchString(b.Enrollment) || d.Store == nil { return false, ErrRoster } raw, _, err := d.Store.Get(ctx, "control/box-fences/"+b.Enrollment+".json") if errors.Is(err, os.ErrNotExist) { return false, nil } if err != nil { return false, err } var f termfence.Record if json.Unmarshal(raw, &f) != nil { return false, ErrRoster } if err := d.ValidateTerminationFenceAuthority(ctx, o, ref, f, now); err != nil { return false, err } return true, nil } // ValidateTerminationFence checks the record against the roster alone. It // cannot see the writer's own fence or quarantine evidence; every consumer // that decides exclusion uses ValidateTerminationFenceAuthority instead. func ValidateTerminationFence(o Observed, ref string, f termfence.Record, now time.Time) error { b, ok := o.Roster.Boxes[ref] at, err := time.Parse(time.RFC3339Nano, f.ObservedAt) if !ok || err != nil || at.IsZero() || at.After(now.Add(5*time.Second)) || f.V != 1 || f.Box != ref || f.Enrollment != b.Enrollment || f.Cloud != b.Cloud { return ErrRoster } if (f.Via != termfence.ViaDescribe || f.RequestID == "") && (f.Via != termfence.ViaEvent || f.EventID == "") { return ErrRoster } // A box never witnesses its own termination. if f.WrittenBy.Box == ref { return ErrRoster } writer, ok := o.Roster.Boxes[f.WrittenBy.Box] if !ok || writer.State != "active" || writer.Enrollment != f.WrittenBy.Enrollment || writer.Region != b.Region || writer.Cloud.Account != b.Cloud.Account { return ErrRoster } _, err = termfence.Verify( termfence.Box{Ref: ref, Enrollment: b.Enrollment, Cloud: b.Cloud}, termfence.Box{Ref: f.WrittenBy.Box, Enrollment: writer.Enrollment, Cloud: writer.Cloud}, termfence.Observation{Via: f.Via, Found: true, InstanceID: f.Cloud.InstanceID, Account: f.Cloud.Account, Region: f.Cloud.Region, State: f.State, RequestID: f.RequestID, EventID: f.EventID}, at) if err != nil { return ErrRoster } return nil } // ValidateTerminationFenceAuthority requires a currently active, unfenced // writer. A self-asserted observation time cannot establish historical authority. // Missing evidence is accepted only when the store proves the object absent. func (d Directory) ValidateTerminationFenceAuthority(ctx context.Context, o Observed, ref string, f termfence.Record, now time.Time) error { if err := ValidateTerminationFence(o, ref, f, now); err != nil { return err } if d.Store == nil { return ErrRoster } writer := o.Roster.Boxes[f.WrittenBy.Box] _, _, err := d.Store.Get(ctx, "control/box-fences/"+writer.Enrollment+".json") if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } return ErrRoster }