package saas import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "strings" "time" "data-terminal-cloud/internal/audit" "data-terminal-cloud/internal/database" "data-terminal-cloud/internal/identity" "data-terminal-cloud/internal/outbox" "data-terminal-cloud/internal/pagination" "github.com/jackc/pgx/v5" ) var ( ErrStorySourceRequired = errors.New("story source is required") ErrPersonalizedMemoryConsentNeeded = errors.New("personalized memory consent is required") ErrReviewStateConflict = errors.New("content review state does not allow this operation") ErrFactReviewStateConflict = errors.New("fact candidate review state does not allow this operation") ) type ConversationTurn struct { ID string `json:"id"` SequenceNo int64 `json:"sequence_no"` Speaker string `json:"speaker"` StartAt time.Time `json:"start_at"` EndAt time.Time `json:"end_at"` FinalText string `json:"final_text"` Interrupted bool `json:"interrupted"` SourceType string `json:"source_type"` } type ConversationTurnPage struct { Items []ConversationTurn `json:"items"` NextCursor string `json:"next_cursor,omitempty"` } type ConversationContentSummary struct { ID string `json:"id"` Type string `json:"type"` Content string `json:"content"` SourceTurnIDs []string `json:"source_turn_ids"` ModelRef string `json:"model_ref"` PromptVersion string `json:"prompt_version"` GenerationVersion int64 `json:"generation_version"` Status string `json:"status"` CreatedAt time.Time `json:"created_at"` } type StorySource struct { TurnID string `json:"turn_id"` SequenceNo int64 `json:"sequence_no"` Speaker string `json:"speaker"` Text string `json:"text"` StartOffset *int `json:"start_offset,omitempty"` EndOffset *int `json:"end_offset,omitempty"` } type FactCandidate struct { ID string `json:"id"` FactType string `json:"fact_type"` Content string `json:"content"` StatementBasis string `json:"statement_basis"` Confidence *float64 `json:"confidence,omitempty"` Sensitivity string `json:"sensitivity"` SourceTurnIDs []string `json:"source_turn_ids"` Status string `json:"status"` Version int64 `json:"version"` } type UpdateFactCandidateInput struct { FactType *string Content *string Sensitivity *string Status *string ErrorType *string Reason string } type Story struct { ID string `json:"id"` SubjectID string `json:"subject_id"` ConversationID string `json:"conversation_id"` Title string `json:"title"` Content string `json:"content"` Status string `json:"status"` Confidence *float64 `json:"confidence,omitempty"` Sensitivity string `json:"sensitivity"` GenerationVersion int64 `json:"generation_version"` RevisionNo int64 `json:"revision_no"` AuthorType string `json:"author_type"` ReviewReason string `json:"review_reason,omitempty"` Version int64 `json:"version"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Sources []StorySource `json:"sources"` Facts []FactCandidate `json:"facts"` } type CuratedMemory struct { ID string `json:"id"` SubjectID string `json:"subject_id"` Content string `json:"content"` SourceRefs []map[string]any `json:"source_refs"` Status string `json:"status"` Sensitivity string `json:"sensitivity"` VersionNo int64 `json:"version_no"` Version int64 `json:"version"` ConfirmedBy string `json:"confirmed_by"` ConfirmedAt time.Time `json:"confirmed_at"` ChangeReason string `json:"change_reason,omitempty"` IndexStatus string `json:"index_status"` LastIndexError string `json:"last_index_error,omitempty"` ProviderIndexStatus string `json:"provider_index_status"` ProviderIndexError string `json:"provider_index_error,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type UpdateMemoryInput struct { Content *string Status *string ErrorType *string Reason string } var contentErrorTypes = map[string]bool{ "PERSON_ERROR": true, "TIME_ERROR": true, "PLACE_ERROR": true, "RELATION_ERROR": true, "OVER_INFERENCE": true, "KEY_POINT_OMISSION": true, "DUPLICATE_CONTENT": true, "UNNATURAL_EXPRESSION": true, "SENSITIVE_INFORMATION": true, } func IsContentErrorType(value string) bool { return contentErrorTypes[value] } func (s *Service) ListConversationTurns(ctx context.Context, principal identity.Principal, conversationPublicID string, limit int, cursor string) (ConversationTurnPage, error) { access, conversationID, err := s.resolveConversationContent(ctx, s.pool, principal, conversationPublicID, identity.CapabilityConversationRead, false) if err != nil { return ConversationTurnPage{}, err } if s.cursor == nil { return ConversationTurnPage{}, errors.New("pagination cursor codec is unavailable") } var afterSequence int64 var afterID string if cursor != "" { decoded, err := s.cursor.Decode(cursor) if err != nil { return ConversationTurnPage{}, pagination.ErrInvalidCursor } if _, err := fmt.Sscan(decoded.SortKey, &afterSequence); err != nil { return ConversationTurnPage{}, pagination.ErrInvalidCursor } afterID = decoded.ID } rows, err := s.pool.Query(ctx, ` SELECT public_id::text,sequence_no,speaker,start_at,end_at,final_text,interrupted,source_type FROM conversation_turn WHERE tenant_id=$1::uuid AND conversation_id=$2::uuid AND ($3::bigint=0 OR (sequence_no,public_id)>($3,$4::uuid)) ORDER BY sequence_no,public_id LIMIT $5`, access.tenantID, conversationID, afterSequence, nullUUID(afterID), limit+1) if err != nil { return ConversationTurnPage{}, fmt.Errorf("list conversation turns: %w", err) } defer rows.Close() items := make([]ConversationTurn, 0, limit+1) for rows.Next() { var item ConversationTurn if err := rows.Scan(&item.ID, &item.SequenceNo, &item.Speaker, &item.StartAt, &item.EndAt, &item.FinalText, &item.Interrupted, &item.SourceType); err != nil { return ConversationTurnPage{}, err } items = append(items, item) } if err := rows.Err(); err != nil { return ConversationTurnPage{}, err } page := ConversationTurnPage{Items: items} if len(items) > limit { page.Items = items[:limit] last := page.Items[len(page.Items)-1] page.NextCursor, err = s.cursor.Encode(fmt.Sprint(last.SequenceNo), last.ID) if err != nil { return ConversationTurnPage{}, err } } return page, nil } func (s *Service) ListConversationContentSummaries(ctx context.Context, principal identity.Principal, conversationPublicID string) ([]ConversationContentSummary, error) { access, conversationID, err := s.resolveConversationContent(ctx, s.pool, principal, conversationPublicID, identity.CapabilityConversationRead, false) if err != nil { return nil, err } rows, err := s.pool.Query(ctx, `SELECT public_id::text,summary_type,content,source_turn_ids,model_ref,prompt_version,generation_version,status,created_at FROM conversation_summary WHERE tenant_id=$1::uuid AND conversation_id=$2::uuid ORDER BY generation_version DESC,summary_type`, access.tenantID, conversationID) if err != nil { return nil, fmt.Errorf("list conversation summaries: %w", err) } defer rows.Close() items := make([]ConversationContentSummary, 0) for rows.Next() { var item ConversationContentSummary var sourceJSON []byte if err := rows.Scan(&item.ID, &item.Type, &item.Content, &sourceJSON, &item.ModelRef, &item.PromptVersion, &item.GenerationVersion, &item.Status, &item.CreatedAt); err != nil { return nil, err } if err := json.Unmarshal(sourceJSON, &item.SourceTurnIDs); err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } func (s *Service) ListSubjectStories(ctx context.Context, principal identity.Principal, subjectPublicID, status string) ([]Story, error) { access, _, err := resolveSubject(ctx, s.pool, principal, subjectPublicID, identity.CapabilitySubjectRead, false) if err != nil { return nil, err } rows, err := s.pool.Query(ctx, storySelectSQL+` WHERE sd.tenant_id=$1::uuid AND sd.subject_id=$2::uuid AND (NULLIF($3,'') IS NULL OR sd.status=$3) ORDER BY sd.created_at DESC,sd.public_id`, access.tenantID, access.subjectID, status) if err != nil { return nil, fmt.Errorf("list subject stories: %w", err) } defer rows.Close() items := make([]Story, 0) for rows.Next() { item, err := scanStory(rows) if err != nil { return nil, err } item.Sources = []StorySource{} item.Facts = []FactCandidate{} items = append(items, item) } return items, rows.Err() } func (s *Service) GetStory(ctx context.Context, principal identity.Principal, storyPublicID string) (Story, error) { _, story, err := s.resolveStory(ctx, s.pool, principal, storyPublicID, identity.CapabilitySubjectRead, false) if err != nil { return Story{}, err } if err := s.loadStoryChildren(ctx, &story); err != nil { return Story{}, err } return story, nil } func (s *Service) UpdateFactCandidate(ctx context.Context, principal identity.Principal, storyPublicID, factPublicID, requestID string, expectedVersion int64, input UpdateFactCandidateInput) (FactCandidate, error) { var result FactCandidate if input.ErrorType != nil && !IsContentErrorType(*input.ErrorType) { return result, ErrFactReviewStateConflict } err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error { access, story, err := s.resolveStory(ctx, tx, principal, storyPublicID, identity.CapabilityStoryReview, true) if err != nil { return err } if story.Status == "CONFIRMED" || story.Status == "REJECTED" || story.Status == "SUPERSEDED" { return ErrFactReviewStateConflict } var internalID, factType, content, statementBasis, sensitivity, status string var confidence *float64 var sourceJSON []byte var version int64 if err := tx.QueryRow(ctx, ` SELECT fc.id::text,fc.fact_type,fc.content,fc.statement_basis,fc.confidence,fc.sensitivity,fc.source_turn_ids,fc.status,fc.version FROM fact_candidate fc JOIN story_draft sd ON sd.id=fc.story_id AND sd.tenant_id=fc.tenant_id WHERE fc.public_id=$1::uuid AND sd.public_id=$2::uuid FOR UPDATE OF fc`, factPublicID, storyPublicID).Scan( &internalID, &factType, &content, &statementBasis, &confidence, &sensitivity, &sourceJSON, &status, &version, ); errors.Is(err, pgx.ErrNoRows) { return ErrNotFound } else if err != nil { return fmt.Errorf("lock fact candidate: %w", err) } if version != expectedVersion { return ErrVersionConflict } if status == "REJECTED" || status == "SUPERSEDED" || (status == "CONFIRMED" && (input.Status == nil || *input.Status != "SUPERSEDED")) { return ErrFactReviewStateConflict } previousFactType, previousContent, previousSensitivity, previousStatus := factType, content, sensitivity, status if input.FactType != nil { factType = strings.TrimSpace(*input.FactType) } if input.Content != nil { content = strings.TrimSpace(*input.Content) } if input.Sensitivity != nil { sensitivity = *input.Sensitivity } if input.Status != nil { status = *input.Status } if status != "CANDIDATE" && status != "CONFIRMED" && status != "REJECTED" && status != "SUPERSEDED" { return ErrFactReviewStateConflict } if _, err := tx.Exec(ctx, `INSERT INTO fact_candidate_revision(fact_candidate_id,version_no,fact_type,content,sensitivity,status,edited_by,edit_reason) VALUES($1::uuid,$2,$3,$4,$5,$6,$7::uuid,$8) ON CONFLICT(fact_candidate_id,version_no) DO NOTHING`, internalID, version, previousFactType, previousContent, previousSensitivity, previousStatus, access.userID, input.Reason); err != nil { return err } row := tx.QueryRow(ctx, ` UPDATE fact_candidate SET fact_type=$1,content=$2,sensitivity=$3,status=$4, reviewed_by=$5::uuid,reviewed_at=now(),review_reason=$6,version=version+1,updated_at=now() WHERE id=$7::uuid RETURNING public_id::text,fact_type,content,statement_basis,confidence,sensitivity,source_turn_ids,status,version`, factType, content, sensitivity, status, access.userID, input.Reason, internalID) if err := scanFactCandidate(row, &result); err != nil { return err } if _, err := tx.Exec(ctx, `INSERT INTO fact_candidate_revision(fact_candidate_id,version_no,fact_type,content,sensitivity,status,edited_by,edit_reason,error_type) VALUES($1::uuid,$2,$3,$4,$5,$6,$7::uuid,$8,$9)`, internalID, result.Version, result.FactType, result.Content, result.Sensitivity, result.Status, access.userID, input.Reason, input.ErrorType); err != nil { return err } action := reviewAction(result.Status, input.Content != nil || input.FactType != nil || input.Sensitivity != nil) if _, err := tx.Exec(ctx, `INSERT INTO content_review_signal(tenant_id,subject_id,resource_type,resource_public_id,action,error_type,reason,model_ref,prompt_version,generation_version,actor_id) SELECT sd.tenant_id,sd.subject_id,'fact',$1::uuid,$2,$3,$4,sr.model_ref,sr.prompt_version,sd.generation_version,$5::uuid FROM story_draft sd JOIN story_revision sr ON sr.story_id=sd.id AND sr.revision_no=1 WHERE sd.public_id=$6::uuid`, factPublicID, action, input.ErrorType, input.Reason, access.userID, storyPublicID); err != nil { return err } return s.audit.Record(ctx, tx, audit.Event{ TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID), Action: "fact_candidate.update", ResourceType: "fact_candidate", ResourceID: strPtr(factPublicID), RequestID: requestID, Result: "success", Detail: map[string]any{"story_id": storyPublicID, "status": result.Status, "reason": input.Reason}, }) }) return result, err } func (s *Service) ReviseStory(ctx context.Context, principal identity.Principal, storyPublicID, content, reason, requestID string, expectedVersion int64, errorType string) (Story, error) { var result Story if errorType != "" && !IsContentErrorType(errorType) { return result, ErrReviewStateConflict } err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error { access, current, err := s.resolveStory(ctx, tx, principal, storyPublicID, identity.CapabilityStoryReview, true) if err != nil { return err } if current.Version != expectedVersion { return ErrVersionConflict } if current.Status == "CONFIRMED" || current.Status == "REJECTED" || current.Status == "SUPERSEDED" { return ErrReviewStateConflict } var currentRevisionID, newRevisionID string if err := tx.QueryRow(ctx, `SELECT current_revision_id::text FROM story_draft WHERE public_id=$1::uuid`, storyPublicID).Scan(¤tRevisionID); err != nil { return err } if err := tx.QueryRow(ctx, `INSERT INTO story_revision(story_id,revision_no,content,author_type,author_id,edit_reason,base_revision_id,error_type) SELECT id,$2,$3,'user',$4::uuid,$5,$6::uuid,NULLIF($7,'') FROM story_draft WHERE public_id=$1::uuid RETURNING id::text`, storyPublicID, current.RevisionNo+1, content, access.userID, reason, currentRevisionID, errorType).Scan(&newRevisionID); err != nil { return fmt.Errorf("insert story revision: %w", err) } if _, err := tx.Exec(ctx, `INSERT INTO story_source(story_revision_id,conversation_turn_id,source_start_offset,source_end_offset,source_role) SELECT $1::uuid,conversation_turn_id,source_start_offset,source_end_offset,source_role FROM story_source WHERE story_revision_id=$2::uuid`, newRevisionID, currentRevisionID); err != nil { return err } row := tx.QueryRow(ctx, storyUpdateReturningSQL, `IN_REVIEW`, newRevisionID, access.userID, reason, storyPublicID) result, err = scanStory(row) if err != nil { return err } if err := insertStoryReviewSignal(ctx, tx, access, storyPublicID, "EDITED", errorType, reason); err != nil { return err } return s.audit.Record(ctx, tx, audit.Event{TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID), Action: "story.revise", ResourceType: "story", ResourceID: strPtr(storyPublicID), RequestID: requestID, Result: "success", Detail: map[string]any{"revision_no": result.RevisionNo, "reason": reason}}) }) if err != nil { return Story{}, err } if err := s.loadStoryChildren(ctx, &result); err != nil { return Story{}, err } return result, nil } func (s *Service) RejectStory(ctx context.Context, principal identity.Principal, storyPublicID, reason, requestID string, expectedVersion int64, errorType string) (Story, error) { var result Story if errorType != "" && !IsContentErrorType(errorType) { return result, ErrReviewStateConflict } err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error { access, current, err := s.resolveStory(ctx, tx, principal, storyPublicID, identity.CapabilityStoryReview, true) if err != nil { return err } if current.Version != expectedVersion { return ErrVersionConflict } if current.Status == "CONFIRMED" || current.Status == "REJECTED" || current.Status == "SUPERSEDED" { return ErrReviewStateConflict } row := tx.QueryRow(ctx, storyStatusReturningSQL, "REJECTED", access.userID, reason, storyPublicID) result, err = scanStory(row) if err != nil { return err } if _, err := tx.Exec(ctx, `UPDATE fact_candidate SET status='REJECTED',reviewed_by=$1::uuid,reviewed_at=now(),review_reason=$2,version=version+1,updated_at=now() WHERE story_id=(SELECT id FROM story_draft WHERE public_id=$3::uuid) AND status='CANDIDATE'`, access.userID, reason, storyPublicID); err != nil { return err } if err := insertStoryReviewSignal(ctx, tx, access, storyPublicID, "REJECTED", errorType, reason); err != nil { return err } return s.audit.Record(ctx, tx, audit.Event{TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID), Action: "story.reject", ResourceType: "story", ResourceID: strPtr(storyPublicID), RequestID: requestID, Result: "success", Detail: map[string]any{"reason": reason}}) }) if err != nil { return Story{}, err } if err := s.loadStoryChildren(ctx, &result); err != nil { return Story{}, err } return result, nil } func (s *Service) ConfirmStory(ctx context.Context, principal identity.Principal, storyPublicID, reason, requestID string, expectedVersion int64) (Story, CuratedMemory, error) { var story Story var memory CuratedMemory err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error { access, current, err := s.resolveStory(ctx, tx, principal, storyPublicID, identity.CapabilityStoryReview, true) if err != nil { return err } if current.Version != expectedVersion { return ErrVersionConflict } if current.Status == "CONFIRMED" || current.Status == "REJECTED" || current.Status == "SUPERSEDED" { return ErrReviewStateConflict } var sourceCount int if err := tx.QueryRow(ctx, `SELECT count(*) FROM story_source WHERE story_revision_id=(SELECT current_revision_id FROM story_draft WHERE public_id=$1::uuid)`, storyPublicID).Scan(&sourceCount); err != nil { return err } if sourceCount == 0 { return ErrStorySourceRequired } var consent bool if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM consent_record WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND consent_type='personalized_memory' AND withdrawn_at IS NULL AND (expires_at IS NULL OR expires_at>now()))`, access.tenantID, access.subjectID).Scan(&consent); err != nil { return err } if !consent { return ErrPersonalizedMemoryConsentNeeded } storyRow := tx.QueryRow(ctx, storyStatusReturningSQL, "CONFIRMED", access.userID, reason, storyPublicID) story, err = scanStory(storyRow) if err != nil { return err } sourceRefs := []map[string]any{{"type": "story", "story_id": storyPublicID, "revision_no": story.RevisionNo}} sourceJSON, _ := json.Marshal(sourceRefs) digest := sha256.Sum256([]byte(story.Content)) var memoryID, memoryVersionID string if err := tx.QueryRow(ctx, `INSERT INTO curated_memory(tenant_id,subject_id,status,sensitivity,valid_from) VALUES($1::uuid,$2::uuid,'ACTIVE',$3,now()) RETURNING id::text,public_id::text`, access.tenantID, access.subjectID, story.Sensitivity).Scan(&memoryID, &memory.ID); err != nil { return fmt.Errorf("create curated memory: %w", err) } if err := tx.QueryRow(ctx, `INSERT INTO memory_version(memory_id,version_no,content,source_refs,confirmed_by,change_reason,content_digest) VALUES($1::uuid,1,$2,$3::jsonb,$4::uuid,$5,$6) RETURNING id::text`, memoryID, story.Content, sourceJSON, access.userID, reason, hex.EncodeToString(digest[:])).Scan(&memoryVersionID); err != nil { return fmt.Errorf("create memory version: %w", err) } if _, err := tx.Exec(ctx, `UPDATE curated_memory SET current_version_id=$1::uuid WHERE id=$2::uuid`, memoryVersionID, memoryID); err != nil { return err } if _, err := tx.Exec(ctx, `INSERT INTO memory_index_entry(tenant_id,subject_id,memory_id,memory_version_id,index_type,status) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,'pgvector','PENDING')`, access.tenantID, access.subjectID, memoryID, memoryVersionID); err != nil { return err } if s.providerMemory { if _, err := tx.Exec(ctx, `INSERT INTO memory_index_entry(tenant_id,subject_id,memory_id,memory_version_id,index_type,status) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,'provider','PENDING')`, access.tenantID, access.subjectID, memoryID, memoryVersionID); err != nil { return err } } if _, err := tx.Exec(ctx, `UPDATE fact_candidate SET status='CONFIRMED',reviewed_by=$1::uuid,reviewed_at=now(),review_reason=$2,version=version+1,updated_at=now() WHERE story_id=(SELECT id FROM story_draft WHERE public_id=$3::uuid) AND status='CANDIDATE'`, access.userID, reason, storyPublicID); err != nil { return err } if err := insertStoryReviewSignal(ctx, tx, access, storyPublicID, "CONFIRMED", "", reason); err != nil { return err } dedupe := "memory-index-v1:" + memoryVersionID payload, _ := json.Marshal(map[string]any{"memory_version_id": memoryVersionID}) if _, err := outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &access.tenantID, AggregateType: "curated_memory", AggregateID: memoryID, EventType: "memory.index_requested", DeduplicationKey: &dedupe, Payload: payload}); err != nil { return err } if s.providerMemory { providerDedupe := "memory-provider-sync-v1:" + memoryVersionID if _, err := outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &access.tenantID, AggregateType: "curated_memory", AggregateID: memoryID, EventType: "memory.provider_sync_requested", DeduplicationKey: &providerDedupe, Payload: payload}); err != nil { return err } } if err := s.audit.Record(ctx, tx, audit.Event{TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID), Action: "story.confirm", ResourceType: "story", ResourceID: strPtr(storyPublicID), RequestID: requestID, Result: "success", Detail: map[string]any{"memory_id": memory.ID, "revision_no": story.RevisionNo}}); err != nil { return err } return nil }) if err != nil { return Story{}, CuratedMemory{}, err } memory, err = s.GetMemory(ctx, principal, memory.ID) if err != nil { return Story{}, CuratedMemory{}, err } if err := s.loadStoryChildren(ctx, &story); err != nil { return Story{}, CuratedMemory{}, err } return story, memory, nil } func (s *Service) ListSubjectMemories(ctx context.Context, principal identity.Principal, subjectPublicID, query string) ([]CuratedMemory, error) { access, _, err := resolveSubject(ctx, s.pool, principal, subjectPublicID, identity.CapabilitySubjectRead, false) if err != nil { return nil, err } rows, err := s.pool.Query(ctx, memorySelectSQL+` WHERE cm.tenant_id=$1::uuid AND cm.subject_id=$2::uuid AND cm.status<>'DELETED' AND (NULLIF($3,'') IS NULL OR strpos(lower(mv.content),lower($3))>0) ORDER BY cm.updated_at DESC,cm.public_id`, access.tenantID, access.subjectID, strings.TrimSpace(query)) if err != nil { return nil, err } defer rows.Close() items := make([]CuratedMemory, 0) for rows.Next() { item, err := scanMemory(rows) if err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } func (s *Service) GetMemory(ctx context.Context, principal identity.Principal, memoryPublicID string) (CuratedMemory, error) { result, err := scanMemory(s.pool.QueryRow(ctx, memorySelectSQL+` JOIN household h ON h.id=sp.household_id AND h.tenant_id=sp.tenant_id JOIN household_member hm ON hm.household_id=h.id AND hm.status='active' JOIN user_account member_account ON member_account.id=hm.user_id AND member_account.tenant_id=h.tenant_id AND member_account.status='active' WHERE cm.public_id=$1::uuid AND sp.status IN ('active','suspended') AND member_account.oidc_issuer=$2 AND member_account.oidc_subject=$3`, memoryPublicID, principal.Issuer, principal.Subject)) if errors.Is(err, pgx.ErrNoRows) { return CuratedMemory{}, ErrAccessDenied } if err != nil { return CuratedMemory{}, err } return result, nil } func (s *Service) UpdateMemory(ctx context.Context, principal identity.Principal, memoryPublicID, requestID string, expectedVersion int64, input UpdateMemoryInput) (CuratedMemory, error) { if input.ErrorType != nil && !IsContentErrorType(*input.ErrorType) { return CuratedMemory{}, ErrReviewStateConflict } err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error { var internalID, tenantID, subjectID, userID, userPublicID, role string var currentVersionID, currentContent, currentStatus string var currentVersionNo, resourceVersion int64 var sourceRefs []byte err := tx.QueryRow(ctx, ` SELECT cm.id::text,cm.tenant_id::text,cm.subject_id::text,member_account.id::text, member_account.public_id::text,hm.role,cm.current_version_id::text,mv.version_no, mv.content,mv.source_refs,cm.status,cm.version FROM curated_memory cm JOIN memory_version mv ON mv.id=cm.current_version_id JOIN subject_profile sp ON sp.id=cm.subject_id AND sp.tenant_id=cm.tenant_id JOIN household h ON h.id=sp.household_id AND h.tenant_id=sp.tenant_id JOIN household_member hm ON hm.household_id=h.id AND hm.status='active' JOIN user_account member_account ON member_account.id=hm.user_id AND member_account.tenant_id=h.tenant_id AND member_account.status='active' WHERE cm.public_id=$1::uuid AND cm.status<>'DELETED' AND sp.status IN ('active','suspended') AND member_account.oidc_issuer=$2 AND member_account.oidc_subject=$3 FOR UPDATE OF cm`, memoryPublicID, principal.Issuer, principal.Subject).Scan( &internalID, &tenantID, &subjectID, &userID, &userPublicID, &role, ¤tVersionID, ¤tVersionNo, ¤tContent, &sourceRefs, ¤tStatus, &resourceVersion) if errors.Is(err, pgx.ErrNoRows) { return ErrAccessDenied } if err != nil { return fmt.Errorf("resolve memory update: %w", err) } if !identity.Can(identity.Role(role), identity.CapabilityStoryReview) { return ErrAccessDenied } if resourceVersion != expectedVersion { return ErrVersionConflict } if currentStatus == "PENDING_DELETE" { return ErrReviewStateConflict } targetContent := currentContent if input.Content != nil { targetContent = strings.TrimSpace(*input.Content) } targetStatus := currentStatus if input.Status != nil { targetStatus = *input.Status } if targetContent == currentContent && targetStatus == currentStatus { return ErrReviewStateConflict } if targetStatus == "ACTIVE" { var consent bool if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM consent_record WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND consent_type='personalized_memory' AND withdrawn_at IS NULL AND (expires_at IS NULL OR expires_at>now()))`, tenantID, subjectID).Scan(&consent); err != nil { return err } if !consent { return ErrPersonalizedMemoryConsentNeeded } } newVersionID := currentVersionID if targetContent != currentContent { digest := sha256.Sum256([]byte(targetContent)) if err := tx.QueryRow(ctx, ` INSERT INTO memory_version(memory_id,version_no,content,source_refs,confirmed_by,change_reason,content_digest,error_type) VALUES($1::uuid,$2,$3,$4::jsonb,$5::uuid,$6,$7,$8) RETURNING id::text`, internalID, currentVersionNo+1, targetContent, sourceRefs, userID, input.Reason, hex.EncodeToString(digest[:]), input.ErrorType).Scan(&newVersionID); err != nil { return fmt.Errorf("create memory revision: %w", err) } } if _, err := tx.Exec(ctx, `UPDATE memory_index_entry SET embedding=NULL,status='STALE',last_error='memory version or status changed',updated_at=now() WHERE memory_id=$1::uuid AND status IN ('PENDING','ACTIVE')`, internalID); err != nil { return err } if targetStatus == "ACTIVE" { if newVersionID == currentVersionID { if _, err := tx.Exec(ctx, `UPDATE memory_index_entry SET status='PENDING',last_error=NULL,updated_at=now() WHERE memory_version_id=$1::uuid AND index_type='pgvector'`, newVersionID); err != nil { return err } } else if _, err := tx.Exec(ctx, `INSERT INTO memory_index_entry(tenant_id,subject_id,memory_id,memory_version_id,index_type,status) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,'pgvector','PENDING')`, tenantID, subjectID, internalID, newVersionID); err != nil { return err } if s.providerMemory { if _, err := tx.Exec(ctx, `INSERT INTO memory_index_entry(tenant_id,subject_id,memory_id,memory_version_id,index_type,status,last_error,updated_at) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,'provider','PENDING',NULL,now()) ON CONFLICT(memory_version_id,index_type) DO UPDATE SET status='PENDING',last_error=NULL,updated_at=now()`, tenantID, subjectID, internalID, newVersionID); err != nil { return err } } } if _, err := tx.Exec(ctx, `UPDATE curated_memory SET current_version_id=$1::uuid,status=$2,version=version+1,updated_at=now() WHERE id=$3::uuid`, newVersionID, targetStatus, internalID); err != nil { return err } action := "EDITED" if targetStatus == "DISABLED" { action = "DISABLED" } else if targetContent == currentContent { action = "CONFIRMED" } if _, err := tx.Exec(ctx, `INSERT INTO content_review_signal(tenant_id,subject_id,resource_type,resource_public_id,action,error_type,reason,model_ref,prompt_version,generation_version,actor_id) SELECT $1::uuid,$2::uuid,'memory',$3::uuid,$4,$5,$6,sr.model_ref,sr.prompt_version,sd.generation_version,$7::uuid FROM memory_version mv LEFT JOIN story_draft sd ON sd.public_id::text=mv.source_refs->0->>'story_id' LEFT JOIN story_revision sr ON sr.id=sd.current_revision_id WHERE mv.id=$8::uuid`, tenantID, subjectID, memoryPublicID, action, input.ErrorType, input.Reason, userID, newVersionID); err != nil { return err } if targetStatus == "ACTIVE" { dedupe := fmt.Sprintf("memory-index-update-v%d:%s", resourceVersion+1, newVersionID) payload, _ := json.Marshal(map[string]any{"memory_version_id": newVersionID}) if _, err := outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &tenantID, AggregateType: "curated_memory", AggregateID: internalID, EventType: "memory.index_requested", DeduplicationKey: &dedupe, Payload: payload}); err != nil { return err } if s.providerMemory { providerDedupe := fmt.Sprintf("memory-provider-sync-update-v%d:%s", resourceVersion+1, newVersionID) if _, err := outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &tenantID, AggregateType: "curated_memory", AggregateID: internalID, EventType: "memory.provider_sync_requested", DeduplicationKey: &providerDedupe, Payload: payload}); err != nil { return err } } } else if s.providerMemory { providerDedupe := fmt.Sprintf("memory-provider-delete-disabled-v%d:%s", resourceVersion+1, internalID) if _, err := outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &tenantID, AggregateType: "curated_memory", AggregateID: internalID, EventType: "memory.provider_delete_requested", DeduplicationKey: &providerDedupe, Payload: []byte(`{"reason":"memory disabled"}`)}); err != nil { return err } } return s.audit.Record(ctx, tx, audit.Event{TenantID: strPtr(tenantID), ActorType: "user", ActorPublicID: strPtr(userPublicID), Action: "memory.update", ResourceType: "memory", ResourceID: strPtr(memoryPublicID), RequestID: requestID, Result: "success", Detail: map[string]any{"from_status": currentStatus, "to_status": targetStatus, "content_revised": targetContent != currentContent, "reason": input.Reason}}) }) if err != nil { return CuratedMemory{}, err } return s.GetMemory(ctx, principal, memoryPublicID) } func (s *Service) RequestMemoryDeletion(ctx context.Context, principal identity.Principal, memoryPublicID, reason, requestID string, expectedVersion int64) (CuratedMemory, error) { err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error { var internalID, tenantID, userPublicID, role, status string var version int64 err := tx.QueryRow(ctx, ` SELECT cm.id::text,cm.tenant_id::text,member_account.public_id::text,hm.role,cm.status,cm.version FROM curated_memory cm JOIN subject_profile sp ON sp.id=cm.subject_id AND sp.tenant_id=cm.tenant_id JOIN household h ON h.id=sp.household_id AND h.tenant_id=sp.tenant_id JOIN household_member hm ON hm.household_id=h.id AND hm.status='active' JOIN user_account member_account ON member_account.id=hm.user_id AND member_account.tenant_id=h.tenant_id AND member_account.status='active' WHERE cm.public_id=$1::uuid AND sp.status IN ('active','suspended') AND member_account.oidc_issuer=$2 AND member_account.oidc_subject=$3 FOR UPDATE OF cm`, memoryPublicID, principal.Issuer, principal.Subject).Scan(&internalID, &tenantID, &userPublicID, &role, &status, &version) if errors.Is(err, pgx.ErrNoRows) { return ErrAccessDenied } if err != nil { return fmt.Errorf("resolve memory deletion: %w", err) } if !identity.Can(identity.Role(role), identity.CapabilityStoryReview) { return ErrAccessDenied } if version != expectedVersion { return ErrVersionConflict } if status == "DELETED" || status == "PENDING_DELETE" { return nil } if _, err := tx.Exec(ctx, `UPDATE curated_memory SET status='PENDING_DELETE',valid_to=COALESCE(valid_to,now()),version=version+1,updated_at=now() WHERE id=$1::uuid`, internalID); err != nil { return err } if _, err := tx.Exec(ctx, `UPDATE memory_index_entry SET embedding=NULL,status='STALE',last_error='memory deletion requested',updated_at=now() WHERE memory_id=$1::uuid AND status<>'DELETED'`, internalID); err != nil { return err } dedupe := "memory-delete:" + internalID payload, _ := json.Marshal(map[string]any{"reason": reason}) if s.providerMemory { providerDedupe := "memory-provider-delete:" + internalID if _, err := outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &tenantID, AggregateType: "curated_memory", AggregateID: internalID, EventType: "memory.provider_delete_requested", DeduplicationKey: &providerDedupe, Payload: payload}); err != nil { return err } } if _, err := outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &tenantID, AggregateType: "curated_memory", AggregateID: internalID, EventType: "memory.delete_requested", DeduplicationKey: &dedupe, Payload: payload}); err != nil { return err } return s.audit.Record(ctx, tx, audit.Event{TenantID: strPtr(tenantID), ActorType: "user", ActorPublicID: strPtr(userPublicID), Action: "memory.delete.request", ResourceType: "memory", ResourceID: strPtr(memoryPublicID), RequestID: requestID, Result: "success", Detail: map[string]any{"reason": reason}}) }) if err != nil { return CuratedMemory{}, err } return s.GetMemory(ctx, principal, memoryPublicID) } type contentAccess struct { tenantID, subjectID, userID, userPublicID string role identity.Role } func reviewAction(status string, edited bool) string { if edited && status == "CANDIDATE" { return "EDITED" } switch status { case "CONFIRMED": return "CONFIRMED" case "REJECTED", "SUPERSEDED": return "REJECTED" default: return "EDITED" } } func insertStoryReviewSignal(ctx context.Context, tx pgx.Tx, access contentAccess, storyPublicID, action, errorType, reason string) error { _, err := tx.Exec(ctx, `INSERT INTO content_review_signal(tenant_id,subject_id,resource_type,resource_public_id,action,error_type,reason,model_ref,prompt_version,generation_version,actor_id) SELECT sd.tenant_id,sd.subject_id,'story',sd.public_id,$2,NULLIF($3,''),$4,sr.model_ref,sr.prompt_version,sd.generation_version,$5::uuid FROM story_draft sd JOIN story_revision sr ON sr.story_id=sd.id AND sr.revision_no=1 WHERE sd.public_id=$1::uuid`, storyPublicID, action, errorType, reason, access.userID) return err } func (s *Service) resolveConversationContent(ctx context.Context, q queryRow, p identity.Principal, publicID string, cap identity.Capability, lock bool) (contentAccess, string, error) { sql := `SELECT c.tenant_id::text,c.subject_id::text,ua.id::text,ua.public_id::text,hm.role,c.id::text FROM conversation c JOIN subject_profile sp ON sp.id=c.subject_id AND sp.tenant_id=c.tenant_id AND sp.status IN ('active','suspended') JOIN household h ON h.id=c.household_id AND h.tenant_id=c.tenant_id JOIN household_member hm ON hm.household_id=h.id AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=h.tenant_id AND ua.status='active' WHERE c.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3` if lock { sql += ` FOR UPDATE OF c` } var a contentAccess var role, conversationID string err := q.QueryRow(ctx, sql, publicID, p.Issuer, p.Subject).Scan(&a.tenantID, &a.subjectID, &a.userID, &a.userPublicID, &role, &conversationID) if errors.Is(err, pgx.ErrNoRows) { return contentAccess{}, "", ErrAccessDenied } if err != nil { return contentAccess{}, "", err } a.role = identity.Role(role) if !identity.Can(a.role, cap) { return contentAccess{}, "", ErrAccessDenied } return a, conversationID, nil } func (s *Service) resolveStory(ctx context.Context, q queryRow, p identity.Principal, publicID string, cap identity.Capability, lock bool) (contentAccess, Story, error) { sql := storySelectSQL + ` JOIN household h ON h.id=sp.household_id AND h.tenant_id=sp.tenant_id JOIN household_member hm ON hm.household_id=h.id AND hm.status='active' JOIN user_account member_account ON member_account.id=hm.user_id AND member_account.tenant_id=h.tenant_id AND member_account.status='active' WHERE sd.public_id=$1::uuid AND sp.status IN ('active','suspended') AND member_account.oidc_issuer=$2 AND member_account.oidc_subject=$3` if lock { sql += ` FOR UPDATE OF sd` } var a contentAccess var story Story var role, tenantID, subjectInternal string targets := storyScanTargets(&story, &tenantID, &subjectInternal) targets = append(targets, &a.userID, &a.userPublicID, &role) row := q.QueryRow(ctx, strings.Replace(sql, `sd.subject_id::text AS subject_internal_id`, `sd.subject_id::text AS subject_internal_id,member_account.id::text,member_account.public_id::text,hm.role`, 1), publicID, p.Issuer, p.Subject) err := row.Scan(targets...) if errors.Is(err, pgx.ErrNoRows) { return contentAccess{}, Story{}, ErrAccessDenied } if err != nil { return contentAccess{}, Story{}, err } a.tenantID = tenantID a.subjectID = subjectInternal a.role = identity.Role(role) if !identity.Can(a.role, cap) { return contentAccess{}, Story{}, ErrAccessDenied } return a, story, nil } const storySelectSQL = `SELECT sd.public_id::text AS story_public_id,sp.public_id::text AS subject_public_id,c.public_id::text AS conversation_public_id,sd.title,sr.content,sd.status,sd.confidence,sd.sensitivity,sd.generation_version,sr.revision_no,sr.author_type,COALESCE(sd.review_reason,''),sd.version,sd.created_at,sd.updated_at,sd.tenant_id::text AS tenant_internal_id,sd.subject_id::text AS subject_internal_id FROM story_draft sd JOIN subject_profile sp ON sp.id=sd.subject_id AND sp.tenant_id=sd.tenant_id JOIN conversation c ON c.id=sd.conversation_id AND c.tenant_id=sd.tenant_id JOIN story_revision sr ON sr.id=sd.current_revision_id` const storyStatusReturningSQL = `UPDATE story_draft sd SET status=$1,reviewed_by=$2::uuid,reviewed_at=now(),review_reason=$3,version=sd.version+1,updated_at=now() FROM subject_profile sp,conversation c,story_revision sr WHERE sd.public_id=$4::uuid AND sp.id=sd.subject_id AND c.id=sd.conversation_id AND sr.id=sd.current_revision_id RETURNING sd.public_id::text,sp.public_id::text,c.public_id::text,sd.title,sr.content,sd.status,sd.confidence,sd.sensitivity,sd.generation_version,sr.revision_no,sr.author_type,COALESCE(sd.review_reason,''),sd.version,sd.created_at,sd.updated_at,sd.tenant_id::text,sd.subject_id::text` const storyUpdateReturningSQL = `UPDATE story_draft sd SET status=$1,current_revision_id=$2::uuid,reviewed_by=$3::uuid,reviewed_at=now(),review_reason=$4,version=sd.version+1,updated_at=now() FROM subject_profile sp,conversation c,story_revision sr WHERE sd.public_id=$5::uuid AND sp.id=sd.subject_id AND c.id=sd.conversation_id AND sr.id=$2::uuid RETURNING sd.public_id::text,sp.public_id::text,c.public_id::text,sd.title,sr.content,sd.status,sd.confidence,sd.sensitivity,sd.generation_version,sr.revision_no,sr.author_type,COALESCE(sd.review_reason,''),sd.version,sd.created_at,sd.updated_at,sd.tenant_id::text,sd.subject_id::text` func storyScanTargets(s *Story, tenantID, subjectInternal *string) []any { return []any{&s.ID, &s.SubjectID, &s.ConversationID, &s.Title, &s.Content, &s.Status, &s.Confidence, &s.Sensitivity, &s.GenerationVersion, &s.RevisionNo, &s.AuthorType, &s.ReviewReason, &s.Version, &s.CreatedAt, &s.UpdatedAt, tenantID, subjectInternal} } func scanStory(row rowScanner) (Story, error) { var s Story var tenant, subject string err := row.Scan(storyScanTargets(&s, &tenant, &subject)...) return s, err } func (s *Service) loadStoryChildren(ctx context.Context, story *Story) error { rows, err := s.pool.Query(ctx, `SELECT ct.public_id::text,ct.sequence_no,ct.speaker,ct.final_text,ss.source_start_offset,ss.source_end_offset FROM story_draft sd JOIN story_source ss ON ss.story_revision_id=sd.current_revision_id JOIN conversation_turn ct ON ct.id=ss.conversation_turn_id WHERE sd.public_id=$1::uuid ORDER BY ct.sequence_no`, story.ID) if err != nil { return err } story.Sources = []StorySource{} for rows.Next() { var x StorySource if err := rows.Scan(&x.TurnID, &x.SequenceNo, &x.Speaker, &x.Text, &x.StartOffset, &x.EndOffset); err != nil { rows.Close() return err } story.Sources = append(story.Sources, x) } rows.Close() rows, err = s.pool.Query(ctx, `SELECT fc.public_id::text,fc.fact_type,fc.content,fc.statement_basis,fc.confidence,fc.sensitivity,fc.source_turn_ids,fc.status,fc.version FROM fact_candidate fc JOIN story_draft sd ON sd.id=fc.story_id WHERE sd.public_id=$1::uuid ORDER BY fc.created_at,fc.public_id`, story.ID) if err != nil { return err } defer rows.Close() story.Facts = []FactCandidate{} for rows.Next() { var x FactCandidate if err := scanFactCandidate(rows, &x); err != nil { return err } story.Facts = append(story.Facts, x) } return rows.Err() } func scanFactCandidate(row rowScanner, result *FactCandidate) error { var source []byte if err := row.Scan(&result.ID, &result.FactType, &result.Content, &result.StatementBasis, &result.Confidence, &result.Sensitivity, &source, &result.Status, &result.Version); err != nil { return err } return json.Unmarshal(source, &result.SourceTurnIDs) } const memorySelectSQL = `SELECT cm.public_id::text,sp.public_id::text,mv.content,mv.source_refs,cm.status,cm.sensitivity,mv.version_no,cm.version,ua.public_id::text,mv.confirmed_at,COALESCE(mv.change_reason,''),COALESCE(mi.status,'PENDING'),COALESCE(mi.last_error,''),COALESCE(provider_mi.status,'DISABLED'),COALESCE(provider_mi.last_error,''),cm.created_at,cm.updated_at FROM curated_memory cm JOIN subject_profile sp ON sp.id=cm.subject_id AND sp.tenant_id=cm.tenant_id JOIN memory_version mv ON mv.id=cm.current_version_id JOIN user_account ua ON ua.id=mv.confirmed_by LEFT JOIN memory_index_entry mi ON mi.memory_version_id=mv.id AND mi.index_type='pgvector' LEFT JOIN memory_index_entry provider_mi ON provider_mi.memory_version_id=mv.id AND provider_mi.index_type='provider'` func scanMemory(row rowScanner) (CuratedMemory, error) { var m CuratedMemory var sourceJSON []byte if err := row.Scan(&m.ID, &m.SubjectID, &m.Content, &sourceJSON, &m.Status, &m.Sensitivity, &m.VersionNo, &m.Version, &m.ConfirmedBy, &m.ConfirmedAt, &m.ChangeReason, &m.IndexStatus, &m.LastIndexError, &m.ProviderIndexStatus, &m.ProviderIndexError, &m.CreatedAt, &m.UpdatedAt); err != nil { return CuratedMemory{}, err } if err := json.Unmarshal(sourceJSON, &m.SourceRefs); err != nil { return CuratedMemory{}, err } return m, nil } func nullUUID(value string) any { if value == "" { return nil } return value }