2026-08-26 06:41:07 +08:00
package saas
import (
"context"
2026-10-08 10:06:44 +08:00
"data-terminal-cloud/internal/devicefleet"
2026-08-26 06:41:07 +08:00
"encoding/json"
"errors"
"fmt"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/concurrency"
"data-terminal-cloud/internal/database"
2026-10-08 10:06:44 +08:00
"data-terminal-cloud/internal/elderagent"
2026-08-26 06:41:07 +08:00
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/outbox"
"data-terminal-cloud/internal/pagination"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
)
var (
ErrAccessDenied = errors . New ( "resource access denied" )
ErrNotFound = errors . New ( "resource not found" )
ErrVersionConflict = errors . New ( "resource version conflict" )
ErrEffectiveConsentExist = errors . New ( "an effective consent already exists" )
ErrPolicyVersionMismatch = errors . New ( "consent policy version mismatch" )
ErrDeviceClaimInvalid = errors . New ( "device claim is invalid" )
ErrDeviceClaimUsed = errors . New ( "device claim was used for another binding" )
ErrDeviceAlreadyBound = errors . New ( "device already has an active binding" )
2026-08-29 21:56:51 +08:00
ErrDeviceBindingBlocked = errors . New ( "device binding change has active blockers" )
ErrDeviceBindingStale = errors . New ( "device binding changed since impact review" )
2026-08-26 06:41:07 +08:00
ErrSubjectDeleteConflict = errors . New ( "subject cannot be deleted while a conversation is active" )
ErrDeleteConfirmation = errors . New ( "subject deletion confirmation does not match" )
)
const (
ConsentTranscriptStorage = "transcript_storage"
ConsentRawAudioStorage = "raw_audio_storage"
ConsentStoryExtraction = "story_extraction"
ConsentPersonalMemory = "personalized_memory"
ConsentFamilySharing = "family_sharing"
)
var consentTypes = [] string {
ConsentTranscriptStorage ,
ConsentRawAudioStorage ,
ConsentStoryExtraction ,
ConsentPersonalMemory ,
ConsentFamilySharing ,
}
type Household struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
Name string `json:"name"`
Status string `json:"status"`
Role identity . Role `json:"role"`
Capabilities [] identity . Capability `json:"capabilities"`
Version int64 `json:"version"`
CreatedAt time . Time `json:"created_at"`
UpdatedAt time . Time `json:"updated_at"`
}
type Subject struct {
2026-10-08 10:06:44 +08:00
VoiceAgent * elderagent . Info `json:"voice_agent,omitempty"`
ID string `json:"id"`
HouseholdID string `json:"household_id"`
PreferredName string `json:"preferred_name"`
Language string `json:"language"`
Timezone string `json:"timezone"`
ProfileData map [ string ] any `json:"profile_data"`
ConversationPreferences map [ string ] any `json:"conversation_preferences"`
Status string `json:"status"`
Version int64 `json:"version"`
CreatedAt time . Time `json:"created_at"`
UpdatedAt time . Time `json:"updated_at"`
2026-08-26 06:41:07 +08:00
}
type Consent struct {
ID string `json:"id"`
SubjectID string `json:"subject_id"`
Type string `json:"type"`
Scope map [ string ] any `json:"scope"`
PolicyVersion string `json:"policy_version"`
NoticeDigest string `json:"notice_digest"`
ActorUserID string `json:"actor_user_id"`
EvidenceRef string `json:"evidence_ref,omitempty"`
GrantedAt time . Time `json:"granted_at"`
ExpiresAt * time . Time `json:"expires_at,omitempty"`
WithdrawnAt * time . Time `json:"withdrawn_at,omitempty"`
Effective bool `json:"effective"`
Version int64 `json:"version"`
}
type CreateSubjectInput struct {
2026-10-08 10:06:44 +08:00
InitialConsents InitialConsentSelection `json:"initial_consents,omitempty"`
PreferredName string `json:"preferred_name"`
Language string `json:"language"`
Timezone string `json:"timezone"`
ProfileData map [ string ] any `json:"profile_data"`
ConversationPreferences map [ string ] any `json:"conversation_preferences"`
2026-08-26 06:41:07 +08:00
}
type PatchSubjectInput struct {
PreferredName * string `json:"preferred_name,omitempty"`
Language * string `json:"language,omitempty"`
Timezone * string `json:"timezone,omitempty"`
ProfileData * map [ string ] any `json:"profile_data,omitempty"`
ConversationPreferences * map [ string ] any `json:"conversation_preferences,omitempty"`
Status * string `json:"status,omitempty"`
}
type GrantConsentInput struct {
Type string `json:"type"`
Scope map [ string ] any `json:"scope"`
PolicyVersion string `json:"policy_version"`
NoticeDigest string `json:"notice_digest"`
EvidenceRef string `json:"evidence_ref,omitempty"`
GrantedAt time . Time `json:"granted_at"`
ExpiresAt * time . Time `json:"expires_at,omitempty"`
}
type Service struct {
2026-10-08 10:06:44 +08:00
Fleet * devicefleet . Service
2026-08-26 06:41:07 +08:00
pool * pgxpool . Pool
audit * audit . Repository
redis * redis . Client
cursor * pagination . Codec
exportSigner exportURLSigner
exportDownloadTTL time . Duration
firmwareStore firmwareObjectStore
firmwareURLTTL time . Duration
2026-08-29 21:56:51 +08:00
providerMemory bool
2026-10-08 10:06:44 +08:00
voiceAgents * elderagent . Service
}
func ( s * Service ) ConfigureVoiceAgents ( agents * elderagent . Service ) { s . voiceAgents = agents }
func ( s * Service ) EnsureSubjectVoiceAgent ( ctx context . Context , principal identity . Principal , publicID string ) ( elderagent . Info , error ) {
if _ , _ , err := resolveSubject ( ctx , s . pool , principal , publicID , identity . CapabilitySubjectWrite , false ); err != nil {
return elderagent . Info {}, err
}
if s . voiceAgents == nil {
return elderagent . Info {}, errors . New ( "voice agents not configured" )
}
return s . voiceAgents . Ensure ( ctx , publicID )
2026-08-29 21:56:51 +08:00
}
func ( s * Service ) ConfigureProviderMemory ( enabled bool ) {
s . providerMemory = enabled
2026-08-26 06:41:07 +08:00
}
func NewService ( pool * pgxpool . Pool , auditRepository * audit . Repository , redisClient * redis . Client , cursorCodec * pagination . Codec ) * Service {
return & Service { pool : pool , audit : auditRepository , redis : redisClient , cursor : cursorCodec }
}
func IsConsentType ( value string ) bool {
for _ , consentType := range consentTypes {
if value == consentType {
return true
}
}
return false
}
func ( s * Service ) ListHouseholds ( ctx context . Context , principal identity . Principal , tenantPublicID string ) ([] Household , error ) {
rows , err := s . pool . Query ( ctx , `
SELECT h.public_id::text, t.public_id::text, h.name, h.status, hm.role,
h.version, h.created_at, h.updated_at
FROM user_account ua
JOIN tenant t ON t.id=ua.tenant_id AND t.status='active'
JOIN household_member hm ON hm.user_id=ua.id AND hm.tenant_id=ua.tenant_id AND hm.status='active'
JOIN household h ON h.id=hm.household_id AND h.tenant_id=hm.tenant_id
WHERE ua.oidc_issuer=$1 AND ua.oidc_subject=$2 AND ua.status='active'
AND h.status <> 'deleted'
AND (NULLIF($3,'') IS NULL OR t.public_id=NULLIF($3,'')::uuid)
ORDER BY h.updated_at DESC, h.public_id` , principal . Issuer , principal . Subject , tenantPublicID )
if err != nil {
return nil , fmt . Errorf ( "list households: %w" , err )
}
defer rows . Close ()
result := make ([] Household , 0 )
for rows . Next () {
var item Household
if err := rows . Scan ( & item . ID , & item . TenantID , & item . Name , & item . Status , & item . Role ,
& item . Version , & item . CreatedAt , & item . UpdatedAt ); err != nil {
return nil , fmt . Errorf ( "scan household: %w" , err )
}
item . Capabilities = identity . CapabilitiesForRole ( item . Role )
result = append ( result , item )
}
if err := rows . Err (); err != nil {
return nil , fmt . Errorf ( "iterate households: %w" , err )
}
return result , nil
}
func ( s * Service ) CreateHousehold ( ctx context . Context , principal identity . Principal , tenantPublicID , name , requestID string ) ( Household , error ) {
var result Household
err := database . Transact ( ctx , s . pool , func ( tx pgx . Tx ) error {
var tenantID , userID , userPublicID string
if err := tx . QueryRow ( ctx , `
SELECT t.id::text, ua.id::text, ua.public_id::text
FROM tenant t JOIN user_account ua ON ua.tenant_id=t.id AND ua.status='active'
WHERE t.public_id=$1::uuid AND t.status='active'
AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3
FOR SHARE OF t, ua` , tenantPublicID , principal . Issuer , principal . Subject ,
). Scan ( & tenantID , & userID , & userPublicID ); errors . Is ( err , pgx . ErrNoRows ) {
return ErrAccessDenied
} else if err != nil {
return fmt . Errorf ( "resolve tenant account: %w" , err )
}
if err := tx . QueryRow ( ctx , `
INSERT INTO household (tenant_id, name, owner_user_id)
VALUES ($1::uuid, $2, $3::uuid)
RETURNING public_id::text, $4::text, name, status, version, created_at, updated_at` ,
tenantID , name , userID , tenantPublicID ,
). Scan ( & result . ID , & result . TenantID , & result . Name , & result . Status ,
& result . Version , & result . CreatedAt , & result . UpdatedAt ); err != nil {
return fmt . Errorf ( "insert household: %w" , err )
}
if _ , err := tx . Exec ( ctx , `
INSERT INTO household_member (tenant_id, household_id, user_id, role)
SELECT $1::uuid, id, $2::uuid, 'owner' FROM household WHERE public_id=$3::uuid` ,
tenantID , userID , result . ID ); err != nil {
return fmt . Errorf ( "insert household owner membership: %w" , err )
}
result . Role = identity . RoleOwner
result . Capabilities = identity . CapabilitiesForRole ( result . Role )
return s . audit . Record ( ctx , tx , audit . Event {
TenantID : strPtr ( tenantID ), ActorType : "user" , ActorPublicID : strPtr ( userPublicID ),
Action : "household.create" , ResourceType : "household" , ResourceID : strPtr ( result . ID ),
RequestID : requestID , Result : "success" , Detail : map [ string ] any { "version" : result . Version },
})
})
return result , err
}
func ( s * Service ) GetHousehold ( ctx context . Context , principal identity . Principal , householdPublicID string ) ( Household , error ) {
var result Household
if err := s . pool . QueryRow ( ctx , householdSelectSQL + ` AND h.public_id=$3::uuid` ,
principal . Issuer , principal . Subject , householdPublicID ,
). Scan ( & result . ID , & result . TenantID , & result . Name , & result . Status , & result . Role ,
& result . Version , & result . CreatedAt , & result . UpdatedAt ); errors . Is ( err , pgx . ErrNoRows ) {
return Household {}, ErrAccessDenied
} else if err != nil {
return Household {}, fmt . Errorf ( "get household: %w" , err )
}
if ! identity . Can ( result . Role , identity . CapabilityHouseholdRead ) {
return Household {}, ErrAccessDenied
}
result . Capabilities = identity . CapabilitiesForRole ( result . Role )
return result , nil
}
func ( s * Service ) PatchHousehold ( ctx context . Context , principal identity . Principal , householdPublicID , name string , expectedVersion int64 , requestID string ) ( Household , error ) {
var result Household
err := database . Transact ( ctx , s . pool , func ( tx pgx . Tx ) error {
access , err := resolveHouseholdAccess ( ctx , tx , principal , householdPublicID , identity . CapabilityHouseholdManage , true )
if err != nil {
return err
}
if err := concurrency . RequireVersion ( expectedVersion , access . version ); err != nil {
return ErrVersionConflict
}
if err := tx . QueryRow ( ctx , `
UPDATE household SET name=$1, version=version+1, updated_at=now()
WHERE id=$2::uuid AND tenant_id=$3::uuid
RETURNING public_id::text, $4::text, name, status, version, created_at, updated_at` ,
name , access . householdID , access . tenantID , access . tenantPublicID ,
). Scan ( & result . ID , & result . TenantID , & result . Name , & result . Status ,
& result . Version , & result . CreatedAt , & result . UpdatedAt ); err != nil {
return fmt . Errorf ( "update household: %w" , err )
}
result . Role = access . role
result . Capabilities = identity . CapabilitiesForRole ( result . Role )
return s . audit . Record ( ctx , tx , audit . Event {
TenantID : strPtr ( access . tenantID ), ActorType : "user" , ActorPublicID : strPtr ( access . userPublicID ),
Action : "household.update" , ResourceType : "household" , ResourceID : strPtr ( result . ID ),
RequestID : requestID , Result : "success" , Detail : map [ string ] any { "from_version" : expectedVersion , "to_version" : result . Version },
})
})
return result , err
}
func ( s * Service ) ListSubjects ( ctx context . Context , principal identity . Principal , householdPublicID string ) ([] Subject , error ) {
access , err := resolveHouseholdAccess ( ctx , s . pool , principal , householdPublicID , identity . CapabilitySubjectRead , false )
if err != nil {
return nil , err
}
rows , err := s . pool . Query ( ctx , `
SELECT s.public_id::text, $1::text, s.preferred_name, s.language, s.timezone,
s.profile_data, s.conversation_preferences, s.status, s.profile_version, s.created_at, s.updated_at
FROM subject_profile s
WHERE s.tenant_id=$2::uuid AND s.household_id=$3::uuid AND s.status IN ('active','suspended')
ORDER BY s.updated_at DESC, s.public_id` , householdPublicID , access . tenantID , access . householdID )
if err != nil {
return nil , fmt . Errorf ( "list subjects: %w" , err )
}
defer rows . Close ()
result := make ([] Subject , 0 )
for rows . Next () {
item , err := scanSubject ( rows )
if err != nil {
return nil , err
}
result = append ( result , item )
}
if err := rows . Err (); err != nil {
return nil , fmt . Errorf ( "iterate subjects: %w" , err )
}
2026-10-08 10:06:44 +08:00
rows . Close ()
if s . voiceAgents != nil {
for n := range result {
info , e := s . voiceAgents . Get ( ctx , result [ n ]. ID )
if e != nil {
return nil , e
}
result [ n ]. VoiceAgent = & info
}
}
2026-08-26 06:41:07 +08:00
return result , nil
}
func ( s * Service ) CreateSubject ( ctx context . Context , principal identity . Principal , householdPublicID , requestID string , input CreateSubjectInput ) ( Subject , error ) {
2026-10-08 10:06:44 +08:00
if err := ValidateInitialConsents ( input . InitialConsents ); err != nil {
return Subject {}, err
}
2026-08-26 06:41:07 +08:00
var result Subject
err := database . Transact ( ctx , s . pool , func ( tx pgx . Tx ) error {
access , err := resolveHouseholdAccess ( ctx , tx , principal , householdPublicID , identity . CapabilitySubjectWrite , true )
if err != nil {
return err
}
profileJSON , _ := json . Marshal ( input . ProfileData )
preferencesJSON , _ := json . Marshal ( input . ConversationPreferences )
row := tx . QueryRow ( ctx , `
INSERT INTO subject_profile (
tenant_id, household_id, preferred_name, language, timezone,
profile_data, conversation_preferences, created_by, updated_by
) VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6::jsonb, $7::jsonb, $8::uuid, $8::uuid)
RETURNING public_id::text, $9::text, preferred_name, language, timezone,
profile_data, conversation_preferences, status, profile_version, created_at, updated_at` ,
access . tenantID , access . householdID , input . PreferredName , input . Language , input . Timezone ,
profileJSON , preferencesJSON , access . userID , householdPublicID )
result , err = scanSubject ( row )
if err != nil {
return fmt . Errorf ( "insert subject: %w" , err )
}
2026-10-08 10:06:44 +08:00
if err := InitializeSubjectMemory ( ctx , tx , access . tenantID , result . ID , access . userID , requestID , input . InitialConsents ); err != nil {
return err
}
2026-08-26 06:41:07 +08:00
return s . audit . Record ( ctx , tx , audit . Event {
TenantID : strPtr ( access . tenantID ), ActorType : "user" , ActorPublicID : strPtr ( access . userPublicID ),
Action : "subject.create" , ResourceType : "subject" , ResourceID : strPtr ( result . ID ),
RequestID : requestID , Result : "success" , Detail : map [ string ] any { "household_id" : householdPublicID , "version" : result . Version },
})
})
2026-10-08 10:06:44 +08:00
if err == nil && s . voiceAgents != nil {
info , provisionErr := s . voiceAgents . Ensure ( ctx , result . ID )
if provisionErr != nil && info . Status == "" {
info = elderagent . Info { Status : "FAILED" , Message : "老人已保存,智能体暂未就绪,请重试" }
}
result . VoiceAgent = & info
}
2026-08-26 06:41:07 +08:00
return result , err
}
func ( s * Service ) GetSubject ( ctx context . Context , principal identity . Principal , subjectPublicID string ) ( Subject , error ) {
access , result , err := resolveSubject ( ctx , s . pool , principal , subjectPublicID , identity . CapabilitySubjectRead , false )
_ = access
2026-10-08 10:06:44 +08:00
if err == nil && s . voiceAgents != nil {
info , e := s . voiceAgents . Get ( ctx , subjectPublicID )
if e != nil {
return result , e
}
result . VoiceAgent = & info
}
2026-08-26 06:41:07 +08:00
return result , err
}
func ( s * Service ) PatchSubject ( ctx context . Context , principal identity . Principal , subjectPublicID , requestID string , expectedVersion int64 , input PatchSubjectInput ) ( Subject , error ) {
var result Subject
err := database . Transact ( ctx , s . pool , func ( tx pgx . Tx ) error {
access , current , err := resolveSubject ( ctx , tx , principal , subjectPublicID , identity . CapabilitySubjectWrite , true )
if err != nil {
return err
}
if current . Version != expectedVersion {
return ErrVersionConflict
}
preferredName , language , timezone , status := current . PreferredName , current . Language , current . Timezone , current . Status
profile , preferences := current . ProfileData , current . ConversationPreferences
if input . PreferredName != nil {
preferredName = * input . PreferredName
}
if input . Language != nil {
language = * input . Language
}
if input . Timezone != nil {
timezone = * input . Timezone
}
if input . Status != nil {
status = * input . Status
}
if input . ProfileData != nil {
profile = * input . ProfileData
}
if input . ConversationPreferences != nil {
preferences = * input . ConversationPreferences
}
profileJSON , _ := json . Marshal ( profile )
preferencesJSON , _ := json . Marshal ( preferences )
row := tx . QueryRow ( ctx , `
UPDATE subject_profile SET preferred_name=$1, language=$2, timezone=$3,
profile_data=$4::jsonb, conversation_preferences=$5::jsonb, status=$6,
updated_by=$7::uuid, profile_version=profile_version+1, updated_at=now()
WHERE id=$8::uuid AND tenant_id=$9::uuid
RETURNING public_id::text, $10::text, preferred_name, language, timezone,
profile_data, conversation_preferences, status, profile_version, created_at, updated_at` ,
preferredName , language , timezone , profileJSON , preferencesJSON , status ,
access . userID , access . subjectID , access . tenantID , access . householdPublicID )
result , err = scanSubject ( row )
if err != nil {
return fmt . Errorf ( "update subject: %w" , err )
}
return s . audit . Record ( ctx , tx , audit . Event {
TenantID : strPtr ( access . tenantID ), ActorType : "user" , ActorPublicID : strPtr ( access . userPublicID ),
Action : "subject.update" , ResourceType : "subject" , ResourceID : strPtr ( result . ID ),
RequestID : requestID , Result : "success" , Detail : map [ string ] any { "from_version" : expectedVersion , "to_version" : result . Version },
})
})
2026-10-08 10:06:44 +08:00
if err == nil && s . voiceAgents != nil {
// The database trigger queues this atomically with the profile change.
// A provider failure must not undo a successfully saved profile.
info , syncErr := s . voiceAgents . Sync ( ctx , result . ID )
if syncErr != nil && info . Status == "" {
info = elderagent . Info { Status : "PENDING" , PersonaSyncStatus : "PENDING" , Message : "资料已保存,等待同步" }
}
result . VoiceAgent = & info
}
2026-08-26 06:41:07 +08:00
return result , err
}
func ( s * Service ) ListConsents ( ctx context . Context , principal identity . Principal , subjectPublicID string ) ([] Consent , error ) {
access , _ , err := resolveSubject ( ctx , s . pool , principal , subjectPublicID , identity . CapabilitySubjectRead , false )
if err != nil {
return nil , err
}
rows , err := s . pool . Query ( ctx , `
SELECT c.public_id::text, $1::text, c.consent_type, c.scope, c.policy_version,
c.notice_digest, ua.public_id::text, COALESCE(c.evidence_ref,''), c.granted_at,
c.expires_at, c.withdrawn_at, c.version
FROM consent_record c JOIN user_account ua ON ua.id=c.actor_user_id AND ua.tenant_id=c.tenant_id
WHERE c.tenant_id=$2::uuid AND c.subject_id=$3::uuid
ORDER BY c.granted_at DESC, c.id DESC` , subjectPublicID , access . tenantID , access . subjectID )
if err != nil {
return nil , fmt . Errorf ( "list consents: %w" , err )
}
defer rows . Close ()
result := make ([] Consent , 0 )
for rows . Next () {
item , err := scanConsent ( rows )
if err != nil {
return nil , err
}
result = append ( result , item )
}
if err := rows . Err (); err != nil {
return nil , fmt . Errorf ( "iterate consents: %w" , err )
}
return result , nil
}
func ( s * Service ) GrantConsent ( ctx context . Context , principal identity . Principal , subjectPublicID , requestID string , input GrantConsentInput ) ( Consent , error ) {
var result Consent
err := database . Transact ( ctx , s . pool , func ( tx pgx . Tx ) error {
access , _ , err := resolveSubject ( ctx , tx , principal , subjectPublicID , identity . CapabilitySubjectWrite , true )
if err != nil {
return err
}
if input . PolicyVersion != access . policyVersion {
return ErrPolicyVersionMismatch
}
var existing 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=$3 AND withdrawn_at IS NULL AND (expires_at IS NULL OR expires_at > now())
)` , access . tenantID , access . subjectID , input . Type ). Scan ( & existing ); err != nil {
return fmt . Errorf ( "check effective consent: %w" , err )
}
if existing {
return ErrEffectiveConsentExist
}
scopeJSON , _ := json . Marshal ( input . Scope )
row := tx . QueryRow ( ctx , `
INSERT INTO consent_record (
tenant_id, subject_id, consent_type, scope, policy_version, notice_digest,
actor_user_id, evidence_ref, granted_at, expires_at
) VALUES ($1::uuid,$2::uuid,$3,$4::jsonb,$5,$6,$7::uuid,NULLIF($8,''),$9,$10)
RETURNING public_id::text, $11::text, consent_type, scope, policy_version,
notice_digest, $12::text, COALESCE(evidence_ref,''), granted_at, expires_at, withdrawn_at, version` ,
access . tenantID , access . subjectID , input . Type , scopeJSON , input . PolicyVersion ,
input . NoticeDigest , access . userID , input . EvidenceRef , input . GrantedAt , input . ExpiresAt ,
subjectPublicID , access . userPublicID )
result , err = scanConsent ( row )
if err != nil {
return fmt . Errorf ( "insert consent: %w" , err )
}
if err := createConsentSnapshot ( ctx , tx , access . tenantID , access . subjectID ); err != nil {
return err
}
return s . audit . Record ( ctx , tx , audit . Event {
TenantID : strPtr ( access . tenantID ), ActorType : "user" , ActorPublicID : strPtr ( access . userPublicID ),
Action : "consent.grant" , ResourceType : "consent" , ResourceID : strPtr ( result . ID ),
RequestID : requestID , Result : "success" , Detail : map [ string ] any { "subject_id" : subjectPublicID , "consent_type" : result . Type },
})
})
return result , err
}
func ( s * Service ) WithdrawConsent ( ctx context . Context , principal identity . Principal , subjectPublicID , consentPublicID , requestID string ) ( Consent , error ) {
var result Consent
err := database . Transact ( ctx , s . pool , func ( tx pgx . Tx ) error {
access , _ , err := resolveSubject ( ctx , tx , principal , subjectPublicID , identity . CapabilitySubjectWrite , true )
if err != nil {
return err
}
row := tx . QueryRow ( ctx , `
SELECT c.public_id::text, $1::text, c.consent_type, c.scope, c.policy_version,
c.notice_digest, ua.public_id::text, COALESCE(c.evidence_ref,''), c.granted_at,
c.expires_at, c.withdrawn_at, c.version
FROM consent_record c JOIN user_account ua ON ua.id=c.actor_user_id AND ua.tenant_id=c.tenant_id
WHERE c.public_id=$2::uuid AND c.tenant_id=$3::uuid AND c.subject_id=$4::uuid
FOR UPDATE OF c` , subjectPublicID , consentPublicID , access . tenantID , access . subjectID )
result , err = scanConsent ( row )
if errors . Is ( err , pgx . ErrNoRows ) {
return ErrNotFound
}
if err != nil {
return fmt . Errorf ( "get consent for withdrawal: %w" , err )
}
if result . WithdrawnAt != nil {
return nil
}
row = tx . QueryRow ( ctx , `
UPDATE consent_record SET withdrawn_at=now(), version=version+1, updated_at=now()
WHERE public_id=$1::uuid
RETURNING public_id::text, $2::text, consent_type, scope, policy_version,
notice_digest, $3::text, COALESCE(evidence_ref,''), granted_at, expires_at, withdrawn_at, version` ,
consentPublicID , subjectPublicID , result . ActorUserID )
result , err = scanConsent ( row )
if err != nil {
return fmt . Errorf ( "withdraw consent: %w" , err )
}
if err := createConsentSnapshot ( ctx , tx , access . tenantID , access . subjectID ); err != nil {
return err
}
if result . Type == "personalized_memory" {
if _ , err := tx . Exec ( ctx , `
UPDATE curated_memory SET status='DISABLED',version=version+1,updated_at=now()
WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND status='ACTIVE'` , access . tenantID , access . subjectID ); err != nil {
return fmt . Errorf ( "disable memories after consent withdrawal: %w" , err )
}
if _ , err := tx . Exec ( ctx , `
UPDATE memory_index_entry SET embedding=NULL,status='STALE',last_error='personalized memory consent withdrawn',updated_at=now()
WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND status IN ('PENDING','ACTIVE')` , access . tenantID , access . subjectID ); err != nil {
return fmt . Errorf ( "invalidate memory index after consent withdrawal: %w" , err )
}
2026-08-29 21:56:51 +08:00
if s . providerMemory {
rows , err := tx . Query ( ctx , `SELECT id::text FROM curated_memory WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND status<>'DELETED' ORDER BY id` , access . tenantID , access . subjectID )
if err != nil {
return fmt . Errorf ( "list provider memories after consent withdrawal: %w" , err )
}
memoryIDs := make ([] string , 0 )
for rows . Next () {
var memoryID string
if err := rows . Scan ( & memoryID ); err != nil {
rows . Close ()
return err
}
memoryIDs = append ( memoryIDs , memoryID )
}
rows . Close ()
if err := rows . Err (); err != nil {
return err
}
for _ , memoryID := range memoryIDs {
dedupe := "memory-provider-delete-consent:" + memoryID + ":" + result . ID
payload , _ := json . Marshal ( map [ string ] string { "reason" : "personalized memory consent withdrawn" , "consent_id" : result . ID })
if _ , err := outbox . NewStore ( s . pool ). Enqueue ( ctx , tx , outbox . NewEvent { TenantID : & access . tenantID , AggregateType : "curated_memory" , AggregateID : memoryID , EventType : "memory.provider_delete_requested" , DeduplicationKey : & dedupe , Payload : payload }); err != nil {
return err
}
}
}
2026-08-26 06:41:07 +08:00
}
affectedConversations , err := s . stopConversationsAfterConsentWithdrawal ( ctx , tx , access . tenantID , access . subjectID , result . Type )
if err != nil {
return err
}
return s . audit . Record ( ctx , tx , audit . Event {
TenantID : strPtr ( access . tenantID ), ActorType : "user" , ActorPublicID : strPtr ( access . userPublicID ),
Action : "consent.withdraw" , ResourceType : "consent" , ResourceID : strPtr ( result . ID ),
RequestID : requestID , Result : "success" , Detail : map [ string ] any {
"subject_id" : subjectPublicID , "consent_type" : result . Type ,
"stopped_conversation_ids" : affectedConversations ,
},
})
})
return result , err
}
func consentWithdrawalStopsConversation ( consentType string ) bool {
switch consentType {
case ConsentTranscriptStorage , ConsentRawAudioStorage , ConsentStoryExtraction , ConsentPersonalMemory :
return true
default :
return false
}
}
func ( s * Service ) stopConversationsAfterConsentWithdrawal (
ctx context . Context ,
tx pgx . Tx ,
tenantID , subjectID , consentType string ,
) ([] string , error ) {
if ! consentWithdrawalStopsConversation ( consentType ) {
return [] string {}, nil
}
type candidate struct {
2026-09-06 17:04:11 +08:00
internalID , publicID , status , runtimeType string
2026-08-26 06:41:07 +08:00
}
rows , err := tx . Query ( ctx , `
2026-09-06 17:04:11 +08:00
SELECT c.id::text,c.public_id::text,c.status,c.runtime_type
2026-08-26 06:41:07 +08:00
FROM conversation c
JOIN consent_snapshot cs ON cs.id=c.consent_snapshot_id AND cs.tenant_id=c.tenant_id
2026-09-06 17:04:11 +08:00
LEFT JOIN rtc_task rt ON rt.conversation_id=c.id
LEFT JOIN hardware_agent_session hs ON hs.conversation_id=c.id
2026-08-26 06:41:07 +08:00
WHERE c.tenant_id=$1::uuid AND c.subject_id=$2::uuid
AND c.status IN ('PREPARING','ROOM_ISSUED','STARTING_AGENT','ACTIVE')
AND cs.effective_consents @> jsonb_build_object($3::text,true)
ORDER BY c.created_at,c.id
2026-09-06 17:04:11 +08:00
FOR UPDATE OF c` , tenantID , subjectID , consentType )
2026-08-26 06:41:07 +08:00
if err != nil {
return nil , fmt . Errorf ( "find conversations affected by consent withdrawal: %w" , err )
}
candidates := make ([] candidate , 0 )
for rows . Next () {
var item candidate
2026-09-06 17:04:11 +08:00
if err := rows . Scan ( & item . internalID , & item . publicID , & item . status , & item . runtimeType ); err != nil {
2026-08-26 06:41:07 +08:00
rows . Close ()
return nil , fmt . Errorf ( "scan consent withdrawal conversation: %w" , err )
}
candidates = append ( candidates , item )
}
if err := rows . Err (); err != nil {
rows . Close ()
return nil , fmt . Errorf ( "iterate consent withdrawal conversations: %w" , err )
}
rows . Close ()
affected := make ([] string , 0 , len ( candidates ))
stopReason := "consent_withdrawn:" + consentType
for _ , item := range candidates {
toStatus := "EXPIRED"
if item . status == "STARTING_AGENT" || item . status == "ACTIVE" {
toStatus = "STOPPING"
}
tag , err := tx . Exec ( ctx , `
UPDATE conversation SET status=$2::varchar,stop_reason=$3,ended_at=CASE WHEN $2::text='EXPIRED' THEN now() ELSE ended_at END,
lease_expires_at=NULL,version=version+1,updated_at=now()
WHERE id=$1::uuid AND status=$4::varchar` , item . internalID , toStatus , stopReason , item . status )
if err != nil {
return nil , fmt . Errorf ( "stop conversation after consent withdrawal: %w" , err )
}
if tag . RowsAffected () != 1 {
return nil , fmt . Errorf ( "conversation changed during consent withdrawal" )
}
2026-09-06 17:04:11 +08:00
if item . runtimeType == "volc_hardware_agent" && toStatus == "EXPIRED" {
_ , err = tx . Exec ( ctx , `UPDATE hardware_agent_session SET status='EXPIRED',updated_at=now() WHERE conversation_id=$1::uuid` , item . internalID )
} else if item . runtimeType == "volc_hardware_agent" {
_ , err = tx . Exec ( ctx , `UPDATE hardware_agent_session SET status='STOPPING',updated_at=now() WHERE conversation_id=$1::uuid` , item . internalID )
} else if toStatus == "EXPIRED" {
2026-08-26 06:41:07 +08:00
_ , err = tx . Exec ( ctx , `UPDATE rtc_task SET status='COMPLETED',lease_expires_at=NULL,updated_at=now() WHERE conversation_id=$1::uuid` , item . internalID )
} else {
_ , err = tx . Exec ( ctx , `UPDATE rtc_task SET status=CASE WHEN status='ACTIVE' THEN 'STOPPING' ELSE 'UNKNOWN' END,lease_expires_at=NULL,updated_at=now() WHERE conversation_id=$1::uuid` , item . internalID )
}
if err != nil {
return nil , fmt . Errorf ( "update RTC after consent withdrawal: %w" , err )
}
if _ , err := tx . Exec ( ctx , `
INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source)
VALUES($1::uuid,$2,$3,$4,'platform')` , item . internalID , item . status , toStatus , "consent withdrawn: " + consentType ); err != nil {
return nil , fmt . Errorf ( "record consent withdrawal stop: %w" , err )
}
2026-09-06 17:04:11 +08:00
if toStatus == "STOPPING" && item . runtimeType != "volc_hardware_agent" {
2026-08-26 06:41:07 +08:00
deduplicationKey := "consent-withdrawal-stop:" + item . internalID + ":" + consentType
payload , _ := json . Marshal ( map [ string ] string {
"conversation_id" : item . publicID ,
"reason" : stopReason ,
})
if _ , err := outbox . NewStore ( s . pool ). Enqueue ( ctx , tx , outbox . NewEvent {
TenantID : strPtr ( tenantID ), AggregateType : "conversation" , AggregateID : item . internalID ,
EventType : "conversation.agent_stop_requested" , DeduplicationKey : & deduplicationKey , Payload : payload ,
}); err != nil {
return nil , fmt . Errorf ( "enqueue consent withdrawal stop: %w" , err )
}
}
affected = append ( affected , item . publicID )
}
return affected , nil
}
const householdSelectSQL = `
SELECT h.public_id::text, t.public_id::text, h.name, h.status, hm.role,
h.version, h.created_at, h.updated_at
FROM household h
JOIN tenant t ON t.id=h.tenant_id AND t.status='active'
JOIN household_member hm ON hm.household_id=h.id AND hm.tenant_id=h.tenant_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 ua.oidc_issuer=$1 AND ua.oidc_subject=$2 AND h.status <> 'deleted'`
type queryRow interface {
QueryRow ( context . Context , string , ... any ) pgx . Row
}
type resourceAccess struct {
tenantID , tenantPublicID , userID , userPublicID string
householdID , householdPublicID , subjectID string
policyVersion string
role identity . Role
version int64
}
func resolveHouseholdAccess ( ctx context . Context , q queryRow , principal identity . Principal , publicID string , capability identity . Capability , lock bool ) ( resourceAccess , error ) {
sql := `
SELECT t.id::text,t.public_id::text,t.policy_version,ua.id::text,ua.public_id::text,
h.id::text,h.public_id::text,hm.role,h.version
FROM household h
JOIN tenant t ON t.id=h.tenant_id AND t.status='active'
JOIN household_member hm ON hm.household_id=h.id AND hm.tenant_id=h.tenant_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 h.public_id=$1::uuid AND h.status <> 'deleted'
AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`
if lock {
sql += ` FOR UPDATE OF h`
}
var a resourceAccess
var role string
err := q . QueryRow ( ctx , sql , publicID , principal . Issuer , principal . Subject ). Scan (
& a . tenantID , & a . tenantPublicID , & a . policyVersion , & a . userID , & a . userPublicID ,
& a . householdID , & a . householdPublicID , & role , & a . version )
if errors . Is ( err , pgx . ErrNoRows ) {
return resourceAccess {}, ErrAccessDenied
}
if err != nil {
return resourceAccess {}, fmt . Errorf ( "resolve household access: %w" , err )
}
a . role = identity . Role ( role )
if ! identity . Can ( a . role , capability ) {
return resourceAccess {}, ErrAccessDenied
}
return a , nil
}
func resolveSubject ( ctx context . Context , q queryRow , principal identity . Principal , publicID string , capability identity . Capability , lock bool ) ( resourceAccess , Subject , error ) {
sql := `
SELECT t.id::text,t.public_id::text,t.policy_version,ua.id::text,ua.public_id::text,
h.id::text,h.public_id::text,s.id::text,hm.role,
s.public_id::text,h.public_id::text,s.preferred_name,s.language,s.timezone,
s.profile_data,s.conversation_preferences,s.status,s.profile_version,s.created_at,s.updated_at
FROM subject_profile s
JOIN household h ON h.id=s.household_id AND h.tenant_id=s.tenant_id AND h.status <> 'deleted'
JOIN tenant t ON t.id=s.tenant_id AND t.status='active'
JOIN household_member hm ON hm.household_id=h.id AND hm.tenant_id=h.tenant_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 s.public_id=$1::uuid AND s.status IN ('active','suspended')
AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`
if lock {
sql += ` FOR UPDATE OF s`
}
var a resourceAccess
var role string
var result Subject
var profileJSON , preferencesJSON [] byte
err := q . QueryRow ( ctx , sql , publicID , principal . Issuer , principal . Subject ). Scan (
& a . tenantID , & a . tenantPublicID , & a . policyVersion , & a . userID , & a . userPublicID ,
& a . householdID , & a . householdPublicID , & a . subjectID , & role ,
& result . ID , & result . HouseholdID , & result . PreferredName , & result . Language , & result . Timezone ,
& profileJSON , & preferencesJSON , & result . Status , & result . Version , & result . CreatedAt , & result . UpdatedAt )
if errors . Is ( err , pgx . ErrNoRows ) {
return resourceAccess {}, Subject {}, ErrAccessDenied
}
if err != nil {
return resourceAccess {}, Subject {}, fmt . Errorf ( "resolve subject: %w" , err )
}
a . role = identity . Role ( role )
if ! identity . Can ( a . role , capability ) {
return resourceAccess {}, Subject {}, ErrAccessDenied
}
if err := decodeObject ( profileJSON , & result . ProfileData ); err != nil {
return resourceAccess {}, Subject {}, err
}
if err := decodeObject ( preferencesJSON , & result . ConversationPreferences ); err != nil {
return resourceAccess {}, Subject {}, err
}
return a , result , nil
}
type rowScanner interface { Scan ( ... any ) error }
func scanSubject ( row rowScanner ) ( Subject , error ) {
var item Subject
var profileJSON , preferencesJSON [] byte
if err := row . Scan ( & item . ID , & item . HouseholdID , & item . PreferredName , & item . Language , & item . Timezone ,
& profileJSON , & preferencesJSON , & item . Status , & item . Version , & item . CreatedAt , & item . UpdatedAt ); err != nil {
return Subject {}, err
}
if err := decodeObject ( profileJSON , & item . ProfileData ); err != nil {
return Subject {}, err
}
if err := decodeObject ( preferencesJSON , & item . ConversationPreferences ); err != nil {
return Subject {}, err
}
return item , nil
}
func scanConsent ( row rowScanner ) ( Consent , error ) {
var item Consent
var scopeJSON [] byte
if err := row . Scan ( & item . ID , & item . SubjectID , & item . Type , & scopeJSON , & item . PolicyVersion ,
& item . NoticeDigest , & item . ActorUserID , & item . EvidenceRef , & item . GrantedAt ,
& item . ExpiresAt , & item . WithdrawnAt , & item . Version ); err != nil {
return Consent {}, err
}
if err := decodeObject ( scopeJSON , & item . Scope ); err != nil {
return Consent {}, err
}
item . Effective = item . WithdrawnAt == nil && ( item . ExpiresAt == nil || item . ExpiresAt . After ( time . Now ()))
return item , nil
}
func createConsentSnapshot ( ctx context . Context , tx pgx . Tx , tenantID , subjectID string ) error {
effective := map [ string ] bool { "voice_chat" : true }
for _ , consentType := range consentTypes {
effective [ consentType ] = false
}
rows , err := tx . Query ( ctx , `
SELECT public_id::text, consent_type FROM consent_record
WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND withdrawn_at IS NULL
AND (expires_at IS NULL OR expires_at > now())
ORDER BY granted_at,id` , tenantID , subjectID )
if err != nil {
return fmt . Errorf ( "query effective consents: %w" , err )
}
defer rows . Close ()
sourceIDs := make ([] string , 0 )
for rows . Next () {
var publicID , consentType string
if err := rows . Scan ( & publicID , & consentType ); err != nil {
return fmt . Errorf ( "scan effective consent: %w" , err )
}
effective [ consentType ] = true
sourceIDs = append ( sourceIDs , publicID )
}
if err := rows . Err (); err != nil {
return fmt . Errorf ( "iterate effective consents: %w" , err )
}
effectiveJSON , _ := json . Marshal ( effective )
sourceJSON , _ := json . Marshal ( sourceIDs )
if _ , err := tx . Exec ( ctx , `
INSERT INTO consent_snapshot (tenant_id,subject_id,snapshot_version,effective_consents,source_consent_ids)
SELECT $1::uuid,$2::uuid,COALESCE(MAX(snapshot_version),0)+1,$3::jsonb,$4::jsonb
FROM consent_snapshot WHERE tenant_id=$1::uuid AND subject_id=$2::uuid` ,
tenantID , subjectID , effectiveJSON , sourceJSON ); err != nil {
return fmt . Errorf ( "insert consent snapshot: %w" , err )
}
return nil
}
func decodeObject ( value [] byte , target * map [ string ] any ) error {
if len ( value ) == 0 {
* target = map [ string ] any {}
return nil
}
if err := json . Unmarshal ( value , target ); err != nil {
return fmt . Errorf ( "decode JSON object: %w" , err )
}
if * target == nil {
* target = map [ string ] any {}
}
return nil
}
func strPtr ( value string ) * string { return & value }