package domain import ( "reflect" "slices" tactical "github.com/pharos-ai-factory/pharos-platform/services/beto/foundation/domain" ) // ProjectSnapshot excludes acquisition clocks and leases: successful unchanged // reads renew separate source evidence without changing this material state. type ProjectSnapshot struct { ProjectID string Source ProviderIdentity ScopeRef, ScopeName string ManagementGeneration uint64 Name string Lifecycle Lifecycle Members []ObservedMember } func (s ProjectSnapshot) Copy() ProjectSnapshot { s.Members = slices.Clone(s.Members) for i := range s.Members { s.Members[i] = s.Members[i].Copy() } return s } func (s ProjectSnapshot) Validate() error { if err := tactical.ValidateID(s.ProjectID); err != nil { return err } if err := s.Source.Validate(); err != nil { return err } if err := ValidateReference(s.ScopeRef); err != nil { return err } if !validName(s.Name) || !validName(s.ScopeName) || s.ManagementGeneration == 0 || s.ManagementGeneration > 9007199254740991 || (s.Lifecycle != Active && s.Lifecycle != Closed) { return tactical.Reject("invalid_project", "A source-managed Project requires bounded names and a management generation") } if len(s.Members) > MaxMembers { return tactical.Reject("membership_limit", "A complete Project snapshot exceeds the supported member bound") } bindings := map[string]string{} for i, member := range s.Members { if err := member.Validate(); err != nil { return err } if member.Identity.Provider != s.Source.Provider || member.Identity.Instance != s.Source.Instance { return tactical.Reject("invalid_member_source", "Members must belong to the enrolled Project source") } if i > 0 && CompareIdentities(s.Members[i-1].Identity, member.Identity) >= 0 { return tactical.Reject("invalid_members", "Complete source memberships must be unique and sorted") } if member.Binding != nil { if _, exists := bindings[member.Binding.BindingID]; exists { return tactical.Reject("invalid_member_binding", "One binding cannot identify distinct source accounts") } bindings[member.Binding.BindingID] = member.Binding.PartyID } } return nil } type Project struct { state ProjectSnapshot events []tactical.DomainEvent } func NewProject(snapshot ProjectSnapshot) (*Project, error) { if snapshot.Lifecycle != Active { return nil, tactical.Reject("invalid_project", "A source observation creates an active Project") } if err := snapshot.Validate(); err != nil { return nil, err } p := &Project{state: snapshot.Copy()} p.raise(nil) return p, nil } func RestoreProject(snapshot ProjectSnapshot) (*Project, error) { if err := snapshot.Validate(); err != nil { return nil, tactical.Corrupt(err.Error()) } return &Project{state: snapshot.Copy()}, nil } func (p *Project) Snapshot() ProjectSnapshot { return p.state.Copy() } // Observe replaces the complete child membership set. Missing members disappear; // a missing source Project is never represented by an empty observation. func (p *Project) Observe(name, scopeName string, members []ObservedMember) (bool, error) { next := p.state.Copy() next.Name, next.ScopeName, next.Members = name, scopeName, members if err := next.Validate(); err != nil { return false, err } next = next.Copy() // Nil and empty complete lists express the same material state. if len(next.Members) == 0 && len(p.state.Members) == 0 { next.Members = p.state.Members } if reflect.DeepEqual(p.state, next) { return false, nil } previous := p.state.Members p.state = next.Copy() p.raise(previous) return true, nil } // ResolveMember only binds a source member still present in the retained // observation. A delayed resolution cannot reintroduce a removed membership. func (p *Project) ResolveMember(identity ProviderIdentity, binding MemberBinding) (bool, error) { members := p.state.Copy().Members for i := range members { if members[i].Identity == identity { members[i].Binding = &binding return p.Observe(p.state.Name, p.state.ScopeName, members) } } return false, nil } func (p *Project) GovernanceIssues() []string { return GovernanceIssues(p.state) } func GovernanceIssues(snapshot ProjectSnapshot) []string { manager, pending, unsupported := false, false, false for _, member := range snapshot.Members { manager = manager || member.Binding != nil && slices.Contains(member.Roles, Administrator) pending = pending || member.Binding == nil unsupported = unsupported || len(member.UnsupportedRoles) > 0 } var issues []string if !manager { issues = append(issues, "missing_administrator") } if pending { issues = append(issues, "unresolved_members") } if unsupported { issues = append(issues, "unsupported_roles") } return issues } func (p *Project) Events() []tactical.DomainEvent { events := make([]tactical.DomainEvent, len(p.events)) for i, event := range p.events { switch value := event.(type) { case ProjectChanged: value.Snapshot = value.Snapshot.Copy() events[i] = value case ProjectAccessChanged: value.AffectedPartyIDs = slices.Clone(value.AffectedPartyIDs) events[i] = value } } return events } func (p *Project) DrainEvents() []tactical.DomainEvent { events := p.Events() p.events = nil return events } func (p *Project) raise(previous []ObservedMember) { p.events = append(p.events, ProjectChanged{Snapshot: p.Snapshot()}) before, after := accessByParty(previous), accessByParty(p.state.Members) parties := map[string]bool{} for id := range before { parties[id] = true } for id := range after { parties[id] = true } var affected []string for id := range parties { if !reflect.DeepEqual(before[id], after[id]) { affected = append(affected, id) } } slices.Sort(affected) if len(affected) > 0 { p.events = append(p.events, ProjectAccessChanged{ProjectID: p.state.ProjectID, AffectedPartyIDs: affected}) } } func accessByParty(members []ObservedMember) map[string][]ObservedMember { result := map[string][]ObservedMember{} for _, member := range members { if member.Binding != nil && len(member.Roles) > 0 { value := member.Copy() value.UnsupportedRoles = nil result[member.Binding.PartyID] = append(result[member.Binding.PartyID], value) } } return result }