| 1 | package workspacestate |
| 2 | |
| 3 | import ( |
| 4 | "crypto/sha256" |
| 5 | "encoding/hex" |
| 6 | "encoding/json" |
| 7 | "slices" |
| 8 | "strings" |
| 9 | ) |
| 10 | |
| 11 | // TopicSessionRemovalToken is shared by the inspection and durable commit |
| 12 | // owners. Rechecking only before staging leaves a cross-process rename gap. |
| 13 | func TopicSessionRemovalToken(state State, workspaceID, topicID string) (string, string, error) { |
| 14 | associations := map[string]any{} |
| 15 | owner := "" |
| 16 | for id, status := range state.SessionStates { |
| 17 | presentation := state.Presentation[id] |
| 18 | if (presentation.TopicID != topicID && "canonical-"+id != topicID) || status.Lifecycle == Deleted { |
| 19 | continue |
| 20 | } |
| 21 | owners := 0 |
| 22 | for wid, workspace := range state.Workspaces { |
| 23 | if !slices.Contains(workspace.SessionIDs, id) { |
| 24 | continue |
| 25 | } |
| 26 | owners++ |
| 27 | if (owner != "" && owner != wid) || (workspaceID != "" && workspaceID != wid) { |
| 28 | return "", "", ErrMutationConflict |
| 29 | } |
| 30 | owner = wid |
| 31 | } |
| 32 | if owners != 1 { |
| 33 | return "", "", ErrMutationConflict |
| 34 | } |
| 35 | associations[id] = []any{presentation, status} |
| 36 | } |
| 37 | if len(associations) == 0 { |
| 38 | return "", "", nil |
| 39 | } |
| 40 | body, err := json.Marshal([]any{owner, topicID, associations, state.Workspaces[owner].Organization}) |
| 41 | if err != nil { |
| 42 | return "", "", err |
| 43 | } |
| 44 | digest := sha256.Sum256(body) |
| 45 | return hex.EncodeToString(digest[:]), owner, nil |
| 46 | } |
| 47 | |
| 48 | // Runs under the same cross-process transaction as lifecycle publication, |
| 49 | // including startup replay. A committed child is an idempotent receipt. |
| 50 | func validateTopicRemovalArchive(state State, op Operation) error { |
| 51 | id, topicArchive := strings.CutPrefix(op.ID, "topic-sessions-") |
| 52 | if !topicArchive || op.Kind != "archive" || op.Phase == "committed" { |
| 53 | return nil |
| 54 | } |
| 55 | removal, exists := state.TopicRemovals[id] |
| 56 | if !exists { |
| 57 | return nil |
| 58 | } |
| 59 | token, _, err := TopicSessionRemovalToken(state, removal.WorkspaceID, removal.TopicID) |
| 60 | if err != nil { |
| 61 | return err |
| 62 | } |
| 63 | if removal.SessionToken == "" { |
| 64 | // A legacy-only intent cannot silently acquire a new formal identity. |
| 65 | if token != "" { |
| 66 | return ErrMutationConflict |
| 67 | } |
| 68 | return nil |
| 69 | } |
| 70 | if token != removal.SessionToken { |
| 71 | return ErrMutationConflict |
| 72 | } |
| 73 | return nil |
| 74 | } |
| 75 |