feat: establish v0.70 platform milestone
ci / verify (push) Canceled after 0s

This commit is contained in:
Codex Local
2026-08-29 21:56:51 +08:00
parent d97bbf7cf5
commit e652516147
133 changed files with 12907 additions and 801 deletions
+225 -31
View File
@@ -23,6 +23,7 @@ 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 {
@@ -74,6 +75,15 @@ type FactCandidate struct {
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"`
@@ -95,29 +105,40 @@ type Story struct {
}
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"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
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
Reason string
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 {
@@ -229,8 +250,92 @@ func (s *Service) GetStory(ctx context.Context, principal identity.Principal, st
return story, nil
}
func (s *Service) ReviseStory(ctx context.Context, principal identity.Principal, storyPublicID, content, reason, requestID string, expectedVersion int64) (Story, error) {
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 {
@@ -246,7 +351,7 @@ func (s *Service) ReviseStory(ctx context.Context, principal identity.Principal,
if err := tx.QueryRow(ctx, `SELECT current_revision_id::text FROM story_draft WHERE public_id=$1::uuid`, storyPublicID).Scan(&currentRevisionID); 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) SELECT id,$2,$3,'user',$4::uuid,$5,$6::uuid FROM story_draft WHERE public_id=$1::uuid RETURNING id::text`, storyPublicID, current.RevisionNo+1, content, access.userID, reason, currentRevisionID).Scan(&newRevisionID); err != nil {
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 {
@@ -257,6 +362,9 @@ func (s *Service) ReviseStory(ctx context.Context, principal identity.Principal,
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 {
@@ -268,8 +376,11 @@ func (s *Service) ReviseStory(ctx context.Context, principal identity.Principal,
return result, nil
}
func (s *Service) RejectStory(ctx context.Context, principal identity.Principal, storyPublicID, reason, requestID string, expectedVersion int64) (Story, error) {
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 {
@@ -289,6 +400,9 @@ func (s *Service) RejectStory(ctx context.Context, principal identity.Principal,
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 {
@@ -349,14 +463,28 @@ func (s *Service) ConfirmStory(ctx context.Context, principal identity.Principal
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
}
@@ -408,6 +536,9 @@ func (s *Service) GetMemory(ctx context.Context, principal identity.Principal, m
}
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
@@ -469,9 +600,9 @@ func (s *Service) UpdateMemory(ctx context.Context, principal identity.Principal
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)
VALUES($1::uuid,$2,$3,$4::jsonb,$5::uuid,$6,$7) RETURNING id::text`,
internalID, currentVersionNo+1, targetContent, sourceRefs, userID, input.Reason, hex.EncodeToString(digest[:])).Scan(&newVersionID); err != nil {
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)
}
}
@@ -487,16 +618,48 @@ func (s *Service) UpdateMemory(ctx context.Context, principal identity.Principal
} 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}})
})
@@ -542,6 +705,12 @@ func (s *Service) RequestMemoryDeletion(ctx context.Context, principal identity.
}
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
}
@@ -558,6 +727,27 @@ type contentAccess struct {
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 {
@@ -641,11 +831,7 @@ func (s *Service) loadStoryChildren(ctx context.Context, story *Story) error {
story.Facts = []FactCandidate{}
for rows.Next() {
var x FactCandidate
var source []byte
if err := rows.Scan(&x.ID, &x.FactType, &x.Content, &x.StatementBasis, &x.Confidence, &x.Sensitivity, &source, &x.Status, &x.Version); err != nil {
return err
}
if err := json.Unmarshal(source, &x.SourceTurnIDs); err != nil {
if err := scanFactCandidate(rows, &x); err != nil {
return err
}
story.Facts = append(story.Facts, x)
@@ -653,12 +839,20 @@ func (s *Service) loadStoryChildren(ctx context.Context, story *Story) error {
return rows.Err()
}
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,''),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'`
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.CreatedAt, &m.UpdatedAt); err != nil {
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 {
+166
View File
@@ -0,0 +1,166 @@
package saas
import (
"context"
"errors"
"fmt"
"strings"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/database"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5"
)
const (
DeviceBindingActionUnbind = "unbind"
DeviceBindingActionTransfer = "transfer"
)
type DeviceBindingImpact struct {
DeviceID string `json:"device_id"`
BindingID string `json:"binding_id"`
Action string `json:"action"`
SourceHouseholdID string `json:"source_household_id"`
SourceSubjectID string `json:"source_subject_id"`
SourceSubjectName string `json:"source_subject_name"`
TargetHouseholdID *string `json:"target_household_id,omitempty"`
TargetSubjectID *string `json:"target_subject_id,omitempty"`
TargetSubjectName *string `json:"target_subject_name,omitempty"`
ActiveConversationID *string `json:"active_conversation_id,omitempty"`
PendingCommandCount int `json:"pending_command_count"`
ActiveFirmwareTaskCount int `json:"active_firmware_task_count"`
Allowed bool `json:"allowed"`
Blockers []string `json:"blockers"`
EvaluatedAt time.Time `json:"evaluated_at"`
}
type ChangeDeviceBindingInput struct {
Action string
ExpectedBindingID string
TargetSubjectID string
Reason string
Acknowledged bool
}
type DeviceBindingChangeResult struct {
DeviceID string `json:"device_id"`
Action string `json:"action"`
PreviousBindingID string `json:"previous_binding_id"`
BindingID *string `json:"binding_id,omitempty"`
HouseholdID *string `json:"household_id,omitempty"`
SubjectID *string `json:"subject_id,omitempty"`
ChangedAt time.Time `json:"changed_at"`
}
func (s *Service) GetDeviceBindingImpact(ctx context.Context, principal identity.Principal, devicePublicID, action, targetSubjectPublicID string) (DeviceBindingImpact, error) {
access, device, err := resolveDevice(ctx, s.pool, principal, devicePublicID, identity.CapabilityDeviceManage, false)
if err != nil {
return DeviceBindingImpact{}, err
}
return s.readDeviceBindingImpact(ctx, s.pool, principal, access, device, action, targetSubjectPublicID)
}
func (s *Service) ChangeDeviceBinding(ctx context.Context, principal identity.Principal, devicePublicID, requestID string, input ChangeDeviceBindingInput) (DeviceBindingChangeResult, error) {
var result DeviceBindingChangeResult
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, device, err := resolveDevice(ctx, tx, principal, devicePublicID, identity.CapabilityDeviceManage, true)
if err != nil {
return err
}
var bindingID string
if err := tx.QueryRow(ctx, `SELECT public_id::text FROM device_binding WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND unbound_at IS NULL FOR UPDATE`, access.deviceID, access.tenantID).Scan(&bindingID); errors.Is(err, pgx.ErrNoRows) {
return ErrDeviceBindingStale
} else if err != nil {
return fmt.Errorf("lock active device binding: %w", err)
}
if bindingID != input.ExpectedBindingID {
return ErrDeviceBindingStale
}
impact, err := s.readDeviceBindingImpact(ctx, tx, principal, access, device, input.Action, input.TargetSubjectID)
if err != nil {
return err
}
if !impact.Allowed {
return ErrDeviceBindingBlocked
}
now := time.Now().UTC()
if _, err := tx.Exec(ctx, `UPDATE device_binding SET unbound_at=$1,unbound_by=$2::uuid,reason=$3 WHERE public_id=$4::uuid AND unbound_at IS NULL`, now, access.userID, input.Action+":"+strings.TrimSpace(input.Reason), bindingID); err != nil {
return fmt.Errorf("close active device binding: %w", err)
}
result = DeviceBindingChangeResult{DeviceID: devicePublicID, Action: input.Action, PreviousBindingID: bindingID, ChangedAt: now}
if input.Action == DeviceBindingActionTransfer {
targetAccess, _, err := resolveSubject(ctx, tx, principal, input.TargetSubjectID, identity.CapabilityDeviceManage, true)
if err != nil || targetAccess.tenantID != access.tenantID {
return ErrAccessDenied
}
var newBindingID string
if err := tx.QueryRow(ctx, `
INSERT INTO device_binding (tenant_id,device_id,household_id,subject_id,bound_by,reason,bound_at)
VALUES ($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6,$7)
RETURNING public_id::text`, access.tenantID, access.deviceID, targetAccess.householdID, targetAccess.subjectID, access.userID, "transfer:"+strings.TrimSpace(input.Reason), now).Scan(&newBindingID); err != nil {
return fmt.Errorf("create transferred device binding: %w", err)
}
result.BindingID = &newBindingID
result.HouseholdID = &targetAccess.householdPublicID
result.SubjectID = &input.TargetSubjectID
}
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
Action: "device.binding." + input.Action, ResourceType: "device", ResourceID: strPtr(devicePublicID),
RequestID: requestID, Result: "success", Detail: map[string]any{
"previous_binding_id": bindingID, "new_binding_id": result.BindingID, "target_household_id": result.HouseholdID,
"target_subject_id": result.SubjectID, "reason": strings.TrimSpace(input.Reason),
},
})
})
return result, err
}
func (s *Service) readDeviceBindingImpact(ctx context.Context, q queryRow, principal identity.Principal, access deviceAccess, device ManagedDevice, action, targetSubjectPublicID string) (DeviceBindingImpact, error) {
if action != DeviceBindingActionUnbind && action != DeviceBindingActionTransfer {
return DeviceBindingImpact{}, ErrNotFound
}
impact := DeviceBindingImpact{
DeviceID: device.ID, BindingID: device.BindingID, Action: action,
SourceHouseholdID: device.HouseholdID, SourceSubjectID: device.SubjectID, SourceSubjectName: device.SubjectName,
Blockers: make([]string, 0), EvaluatedAt: time.Now().UTC(),
}
if action == DeviceBindingActionTransfer {
if targetSubjectPublicID == "" || targetSubjectPublicID == device.SubjectID {
impact.Blockers = append(impact.Blockers, "target_subject_required")
} else {
targetAccess, target, err := resolveSubject(ctx, q, principal, targetSubjectPublicID, identity.CapabilityDeviceManage, false)
if err != nil || targetAccess.tenantID != access.tenantID {
return DeviceBindingImpact{}, ErrAccessDenied
}
impact.TargetHouseholdID = &target.HouseholdID
impact.TargetSubjectID = &target.ID
impact.TargetSubjectName = &target.PreferredName
}
}
var activeConversation string
err := q.QueryRow(ctx, `SELECT public_id::text FROM conversation WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status NOT IN ('COMPLETED','FAILED','EXPIRED') ORDER BY created_at DESC LIMIT 1`, access.deviceID, access.tenantID).Scan(&activeConversation)
if err == nil {
impact.ActiveConversationID = &activeConversation
impact.Blockers = append(impact.Blockers, "active_conversation")
} else if !errors.Is(err, pgx.ErrNoRows) {
return DeviceBindingImpact{}, fmt.Errorf("check active device conversation: %w", err)
}
if err := q.QueryRow(ctx, `SELECT count(*) FROM device_command WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status IN ('QUEUED','DELIVERED','ACKNOWLEDGED') AND expires_at>now()`, access.deviceID, access.tenantID).Scan(&impact.PendingCommandCount); err != nil {
return DeviceBindingImpact{}, fmt.Errorf("count pending device commands: %w", err)
}
if impact.PendingCommandCount > 0 {
impact.Blockers = append(impact.Blockers, "pending_device_commands")
}
if err := q.QueryRow(ctx, `SELECT count(*) FROM firmware_assignment WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status IN ('PENDING','COMMAND_QUEUED','DOWNLOADING','INSTALLING')`, access.deviceID, access.tenantID).Scan(&impact.ActiveFirmwareTaskCount); err != nil {
return DeviceBindingImpact{}, fmt.Errorf("count active firmware assignments: %w", err)
}
if impact.ActiveFirmwareTaskCount > 0 {
impact.Blockers = append(impact.Blockers, "active_firmware_task")
}
impact.Allowed = len(impact.Blockers) == 0
return impact, nil
}
@@ -0,0 +1,107 @@
package saas
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"testing"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/identity"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestDeviceBindingImpactTransferAndUnbind(t *testing.T) {
databaseURL := os.Getenv("TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
devicePublicID := uuid.NewString()
targetSubjectPublicID := uuid.NewString()
serialSum := sha256.Sum256([]byte(devicePublicID))
serialDigest := hex.EncodeToString(serialSum[:])
var deviceID, targetSubjectID string
if err := pool.QueryRow(ctx, `
INSERT INTO subject_profile (public_id,tenant_id,household_id,preferred_name,status,profile_version)
SELECT $1::uuid,t.id,h.id,'绑定转移测试长者','active',1
FROM tenant t JOIN household h ON h.tenant_id=t.id
WHERE t.public_id=$2::uuid AND h.public_id=$3::uuid
RETURNING id::text`, targetSubjectPublicID, testTenantPublicID, "10000000-0000-0000-0000-000000000211").Scan(&targetSubjectID); err != nil {
t.Fatalf("insert target subject: %v", err)
}
if err := pool.QueryRow(ctx, `
INSERT INTO device (public_id,tenant_id,serial_digest,model,hardware_revision,status,credential_ref,minimum_security_version)
SELECT $1::uuid,id,$2,'binding-test-terminal','test-rev','registered','db://binding-test','test-v1'
FROM tenant WHERE public_id=$3::uuid
RETURNING id::text`, devicePublicID, serialDigest, testTenantPublicID).Scan(&deviceID); err != nil {
t.Fatalf("insert device: %v", err)
}
if _, err := pool.Exec(ctx, `INSERT INTO device_twin (device_id,tenant_id) SELECT id,tenant_id FROM device WHERE id=$1::uuid`, deviceID); err != nil {
t.Fatalf("insert device twin: %v", err)
}
var sourceBindingID string
if err := pool.QueryRow(ctx, `
INSERT INTO device_binding (tenant_id,device_id,household_id,subject_id,bound_by,reason)
SELECT d.tenant_id,d.id,h.id,sp.id,ua.id,'integration-test'
FROM device d
JOIN household h ON h.tenant_id=d.tenant_id AND h.public_id=$2::uuid
JOIN subject_profile sp ON sp.household_id=h.id AND sp.public_id=$3::uuid
JOIN user_account ua ON ua.tenant_id=d.tenant_id AND ua.oidc_issuer=$4 AND ua.oidc_subject=$5
WHERE d.id=$1::uuid
RETURNING public_id::text`, deviceID, "10000000-0000-0000-0000-000000000211", "10000000-0000-0000-0000-000000000411", testIssuer, testOwnerSubject).Scan(&sourceBindingID); err != nil {
t.Fatalf("insert source binding: %v", err)
}
defer func() {
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id LIKE 'device-binding-integration-%'`)
_, _ = pool.Exec(ctx, `DELETE FROM device_binding WHERE device_id=$1::uuid`, deviceID)
_, _ = pool.Exec(ctx, `DELETE FROM device_twin WHERE device_id=$1::uuid`, deviceID)
_, _ = pool.Exec(ctx, `DELETE FROM device WHERE id=$1::uuid`, deviceID)
_, _ = pool.Exec(ctx, `DELETE FROM subject_profile WHERE id=$1::uuid`, targetSubjectID)
}()
service := NewService(pool, audit.NewRepository(), nil, nil)
owner := identity.Principal{Issuer: testIssuer, Subject: testOwnerSubject}
impact, err := service.GetDeviceBindingImpact(ctx, owner, devicePublicID, DeviceBindingActionTransfer, targetSubjectPublicID)
if err != nil || !impact.Allowed || impact.BindingID != sourceBindingID || impact.TargetSubjectID == nil || *impact.TargetSubjectID != targetSubjectPublicID {
t.Fatalf("unexpected transfer impact: %+v err=%v", impact, err)
}
if _, err := service.ChangeDeviceBinding(ctx, owner, devicePublicID, "device-binding-integration-stale", ChangeDeviceBindingInput{
Action: DeviceBindingActionTransfer, ExpectedBindingID: uuid.NewString(), TargetSubjectID: targetSubjectPublicID, Reason: "stale review", Acknowledged: true,
}); !errors.Is(err, ErrDeviceBindingStale) {
t.Fatalf("stale binding error=%v", err)
}
transferred, err := service.ChangeDeviceBinding(ctx, owner, devicePublicID, "device-binding-integration-transfer", ChangeDeviceBindingInput{
Action: DeviceBindingActionTransfer, ExpectedBindingID: impact.BindingID, TargetSubjectID: targetSubjectPublicID, Reason: "change assigned elder", Acknowledged: true,
})
if err != nil || transferred.BindingID == nil || transferred.SubjectID == nil || *transferred.SubjectID != targetSubjectPublicID {
t.Fatalf("transfer result=%+v err=%v", transferred, err)
}
device, err := service.GetDevice(ctx, owner, devicePublicID)
if err != nil || device.BindingID != *transferred.BindingID || device.SubjectID != targetSubjectPublicID {
t.Fatalf("transferred device=%+v err=%v", device, err)
}
unbindImpact, err := service.GetDeviceBindingImpact(ctx, owner, devicePublicID, DeviceBindingActionUnbind, "")
if err != nil || !unbindImpact.Allowed {
t.Fatalf("unbind impact=%+v err=%v", unbindImpact, err)
}
unbound, err := service.ChangeDeviceBinding(ctx, owner, devicePublicID, "device-binding-integration-unbind", ChangeDeviceBindingInput{
Action: DeviceBindingActionUnbind, ExpectedBindingID: unbindImpact.BindingID, Reason: "device removed from service", Acknowledged: true,
})
if err != nil || unbound.BindingID != nil {
t.Fatalf("unbind result=%+v err=%v", unbound, err)
}
if _, err := service.GetDevice(ctx, owner, devicePublicID); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("unbound device should no longer be visible, error=%v", err)
}
}
+5 -4
View File
@@ -18,6 +18,7 @@ import (
type ManagedDevice struct {
ID string `json:"id"`
BindingID string `json:"binding_id"`
HouseholdID string `json:"household_id"`
SubjectID string `json:"subject_id"`
SubjectName string `json:"subject_name"`
@@ -66,7 +67,7 @@ type DevicePresence struct {
func (s *Service) ListDevices(ctx context.Context, principal identity.Principal) ([]ManagedDevice, error) {
rows, err := s.pool.Query(ctx, `
SELECT d.public_id::text,h.public_id::text,sp.public_id::text,sp.preferred_name,
SELECT d.public_id::text,b.public_id::text,h.public_id::text,sp.public_id::text,sp.preferred_name,
d.model,d.hardware_revision,d.status,d.minimum_security_version,d.minimum_firmware_version,dt.firmware_version,
dt.desired_revision,dt.reported_revision,dt.last_reported_at,b.bound_at,d.registered_at,d.version,hm.role
FROM user_account ua
@@ -86,7 +87,7 @@ func (s *Service) ListDevices(ctx context.Context, principal identity.Principal)
for rows.Next() {
var item ManagedDevice
var role identity.Role
if err := rows.Scan(&item.ID, &item.HouseholdID, &item.SubjectID, &item.SubjectName,
if err := rows.Scan(&item.ID, &item.BindingID, &item.HouseholdID, &item.SubjectID, &item.SubjectName,
&item.Model, &item.HardwareRevision, &item.Status, &item.MinimumSecurityVersion, &item.MinimumFirmwareVersion, &item.FirmwareVersion,
&item.DesiredRevision, &item.ReportedRevision, &item.LastReportedAt, &item.BoundAt, &item.RegisteredAt, &item.Version, &role); err != nil {
return nil, fmt.Errorf("scan managed device: %w", err)
@@ -189,7 +190,7 @@ type deviceAccess struct {
func resolveDevice(ctx context.Context, q queryRow, principal identity.Principal, publicID string, capability identity.Capability, lock bool) (deviceAccess, ManagedDevice, error) {
sql := `
SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text,h.id::text,h.public_id::text,d.id::text,hm.role,
d.public_id::text,h.public_id::text,sp.public_id::text,sp.preferred_name,d.model,d.hardware_revision,
d.public_id::text,b.public_id::text,h.public_id::text,sp.public_id::text,sp.preferred_name,d.model,d.hardware_revision,
d.status,d.minimum_security_version,d.minimum_firmware_version,dt.firmware_version,dt.desired_revision,dt.reported_revision,
dt.last_reported_at,b.bound_at,d.registered_at,d.version
FROM device d
@@ -209,7 +210,7 @@ func resolveDevice(ctx context.Context, q queryRow, principal identity.Principal
var role string
err := q.QueryRow(ctx, sql, publicID, principal.Issuer, principal.Subject).Scan(
&access.tenantID, &access.tenantPublicID, &access.userID, &access.userPublicID, &access.householdID, &access.householdPublicID, &access.deviceID, &role,
&device.ID, &device.HouseholdID, &device.SubjectID, &device.SubjectName, &device.Model, &device.HardwareRevision,
&device.ID, &device.BindingID, &device.HouseholdID, &device.SubjectID, &device.SubjectName, &device.Model, &device.HardwareRevision,
&device.Status, &device.MinimumSecurityVersion, &device.MinimumFirmwareVersion, &device.FirmwareVersion, &device.DesiredRevision, &device.ReportedRevision,
&device.LastReportedAt, &device.BoundAt, &device.RegisteredAt, &device.Version)
if errors.Is(err, pgx.ErrNoRows) {
+144 -8
View File
@@ -62,7 +62,7 @@ func (s *Service) CreatePrivacyRequest(ctx context.Context, principal identity.P
if input.Scope == nil {
input.Scope = map[string]any{}
}
if input.RequestType != "ACCESS" && input.RequestType != "DELETE" {
if input.RequestType != "ACCESS" && input.RequestType != "CORRECTION" && input.RequestType != "CONSENT_WITHDRAWAL" && input.RequestType != "DELETE" {
return PrivacyRequest{}, false, ErrNotFound
}
if input.RequestType == "DELETE" {
@@ -152,11 +152,115 @@ func (s *Service) CreatePrivacyRequest(ctx context.Context, principal identity.P
created = true
eventType := "privacy.access_requested"
action := "privacy.access.request"
if input.RequestType == "ACCESS" {
enqueue := true
auditDetail := map[string]any{"subject_id": subjectPublicID, "categories": input.Scope["categories"]}
switch input.RequestType {
case "ACCESS":
if _, err := tx.Exec(ctx, `INSERT INTO privacy_subtask(privacy_request_id,subsystem,target_type,target_ref) VALUES($1::uuid,'postgresql','subject_data_inventory',$2)`, internalID, subjectPublicID); err != nil {
return fmt.Errorf("create privacy subtask: %w", err)
}
} else {
case "CORRECTION":
corrections, ok := input.Scope["corrections"].(map[string]any)
if !ok || len(corrections) == 0 {
return ErrNotFound
}
preferredName, language, timezone := subject.PreferredName, subject.Language, subject.Timezone
profile, preferences := subject.ProfileData, subject.ConversationPreferences
changedFields := make([]string, 0, len(corrections))
if value, ok := corrections["preferred_name"].(string); ok {
preferredName = value
changedFields = append(changedFields, "preferred_name")
}
if value, ok := corrections["language"].(string); ok {
language = value
changedFields = append(changedFields, "language")
}
if value, ok := corrections["timezone"].(string); ok {
timezone = value
changedFields = append(changedFields, "timezone")
}
if value, ok := corrections["profile_data"].(map[string]any); ok {
profile = value
changedFields = append(changedFields, "profile_data")
}
if value, ok := corrections["conversation_preferences"].(map[string]any); ok {
preferences = value
changedFields = append(changedFields, "conversation_preferences")
}
if len(changedFields) == 0 {
return ErrNotFound
}
profileJSON, _ := json.Marshal(profile)
preferencesJSON, _ := json.Marshal(preferences)
if _, err := tx.Exec(ctx, `UPDATE subject_profile SET preferred_name=$1,language=$2,timezone=$3,profile_data=$4::jsonb,conversation_preferences=$5::jsonb,profile_version=profile_version+1,updated_by=$6::uuid,updated_at=now() WHERE id=$7::uuid AND tenant_id=$8::uuid`, preferredName, language, timezone, profileJSON, preferencesJSON, access.userID, access.subjectID, access.tenantID); err != nil {
return fmt.Errorf("apply privacy correction: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO privacy_subtask(privacy_request_id,subsystem,target_type,target_ref,status,attempts,completed_at) VALUES($1::uuid,'postgresql','subject_profile_correction',$2,'COMPLETED',1,now())`, internalID, subjectPublicID); err != nil {
return fmt.Errorf("complete correction subtask: %w", err)
}
resultJSON, _ := json.Marshal(map[string]any{"content_included": false, "corrected_fields": changedFields, "profile_version": subject.Version + 1})
if _, err := tx.Exec(ctx, `UPDATE privacy_request SET status='COMPLETED',result_summary=$2::jsonb,completed_at=now(),version=version+1,updated_at=now() WHERE id=$1::uuid`, internalID, resultJSON); err != nil {
return fmt.Errorf("complete correction request: %w", err)
}
enqueue = false
action = "privacy.correction.request"
auditDetail = map[string]any{"subject_id": subjectPublicID, "corrected_fields": changedFields}
case "CONSENT_WITHDRAWAL":
requestedTypes := privacyScopeStrings(input.Scope, "consent_types")
if len(requestedTypes) == 0 {
return ErrNotFound
}
rows, err := tx.Query(ctx, `SELECT consent_type FROM consent_record WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND consent_type=ANY($3::text[]) AND withdrawn_at IS NULL AND granted_at<=now() AND (expires_at IS NULL OR expires_at>now()) FOR UPDATE`, access.tenantID, access.subjectID, requestedTypes)
if err != nil {
return fmt.Errorf("lock consents for privacy withdrawal: %w", err)
}
withdrawnTypes := make([]string, 0, len(requestedTypes))
for rows.Next() {
var consentType string
if err := rows.Scan(&consentType); err != nil {
rows.Close()
return err
}
withdrawnTypes = append(withdrawnTypes, consentType)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
if _, err := tx.Exec(ctx, `UPDATE consent_record SET withdrawn_at=now(),version=version+1,updated_at=now() WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND consent_type=ANY($3::text[]) AND withdrawn_at IS NULL AND granted_at<=now() AND (expires_at IS NULL OR expires_at>now())`, access.tenantID, access.subjectID, requestedTypes); err != nil {
return fmt.Errorf("withdraw privacy request consents: %w", err)
}
if err := createConsentSnapshot(ctx, tx, access.tenantID, access.subjectID); err != nil {
return err
}
if containsString(withdrawnTypes, ConsentPersonalMemory) {
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 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 err
}
}
stopped := make([]string, 0)
for _, consentType := range withdrawnTypes {
items, err := s.stopConversationsAfterConsentWithdrawal(ctx, tx, access.tenantID, access.subjectID, consentType)
if err != nil {
return err
}
stopped = append(stopped, items...)
}
if _, err := tx.Exec(ctx, `INSERT INTO privacy_subtask(privacy_request_id,subsystem,target_type,target_ref,status,attempts,completed_at) VALUES($1::uuid,'postgresql','consent_withdrawal',$2,'COMPLETED',1,now())`, internalID, subjectPublicID); err != nil {
return fmt.Errorf("complete consent withdrawal subtask: %w", err)
}
resultJSON, _ := json.Marshal(map[string]any{"content_included": false, "requested_consent_types": requestedTypes, "withdrawn_consent_types": withdrawnTypes, "stopped_conversation_count": len(stopped)})
if _, err := tx.Exec(ctx, `UPDATE privacy_request SET status='COMPLETED',result_summary=$2::jsonb,completed_at=now(),version=version+1,updated_at=now() WHERE id=$1::uuid`, internalID, resultJSON); err != nil {
return fmt.Errorf("complete consent withdrawal request: %w", err)
}
enqueue = false
action = "privacy.consent_withdrawal.request"
auditDetail = map[string]any{"subject_id": subjectPublicID, "requested_consent_types": requestedTypes, "withdrawn_consent_types": withdrawnTypes, "stopped_conversation_ids": stopped}
case "DELETE":
tasks := [][2]string{{"redis", "ephemeral_keys"}, {"export_files", "private_exports"}, {"object_storage", "subject_media"}, {"pgvector", "memory_embeddings"}, {"provider_index", "external_memory_index"}, {"postgresql", "subject_personal_data"}}
for _, task := range tasks {
if _, err := tx.Exec(ctx, `INSERT INTO privacy_subtask(privacy_request_id,subsystem,target_type,target_ref) VALUES($1::uuid,$2,$3,$4)`, internalID, task[0], task[1], subjectPublicID); err != nil {
@@ -178,12 +282,14 @@ func (s *Service) CreatePrivacyRequest(ctx context.Context, principal identity.P
eventType = "privacy.delete_requested"
action = "privacy.delete.request"
}
dedupe := strings.ReplaceAll(eventType, ".", "-") + ":" + internalID
payload, _ := json.Marshal(map[string]any{"request_id": internalID})
if _, err := outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &access.tenantID, AggregateType: "privacy_request", AggregateID: internalID, EventType: eventType, DeduplicationKey: &dedupe, Payload: payload}); err != nil {
return err
if enqueue {
dedupe := strings.ReplaceAll(eventType, ".", "-") + ":" + internalID
payload, _ := json.Marshal(map[string]any{"request_id": internalID})
if _, err := outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &access.tenantID, AggregateType: "privacy_request", AggregateID: internalID, EventType: eventType, DeduplicationKey: &dedupe, Payload: payload}); err != nil {
return err
}
}
return s.audit.Record(ctx, tx, audit.Event{TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID), Action: action, ResourceType: "privacy_request", ResourceID: strPtr(publicID), RequestID: requestID, Result: "success", Detail: map[string]any{"subject_id": subjectPublicID, "scope": input.Scope}})
return s.audit.Record(ctx, tx, audit.Event{TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID), Action: action, ResourceType: "privacy_request", ResourceID: strPtr(publicID), RequestID: requestID, Result: "success", Detail: auditDetail})
})
if err != nil {
return PrivacyRequest{}, false, err
@@ -192,6 +298,36 @@ func (s *Service) CreatePrivacyRequest(ctx context.Context, principal identity.P
return result, created, err
}
func privacyScopeStrings(scope map[string]any, key string) []string {
value, ok := scope[key]
if !ok {
return nil
}
switch values := value.(type) {
case []string:
return values
case []any:
result := make([]string, 0, len(values))
for _, item := range values {
if text, ok := item.(string); ok {
result = append(result, text)
}
}
return result
default:
return nil
}
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func (s *Service) ListPrivacyRequests(ctx context.Context, principal identity.Principal, subjectPublicID string) ([]PrivacyRequest, error) {
access, _, err := resolveSubject(ctx, s.pool, principal, subjectPublicID, identity.CapabilityPrivacyRead, false)
if err != nil {
+33
View File
@@ -28,6 +28,8 @@ var (
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")
ErrDeviceBindingBlocked = errors.New("device binding change has active blockers")
ErrDeviceBindingStale = errors.New("device binding changed since impact review")
ErrSubjectDeleteConflict = errors.New("subject cannot be deleted while a conversation is active")
ErrDeleteConfirmation = errors.New("subject deletion confirmation does not match")
)
@@ -126,6 +128,11 @@ type Service struct {
exportDownloadTTL time.Duration
firmwareStore firmwareObjectStore
firmwareURLTTL time.Duration
providerMemory bool
}
func (s *Service) ConfigureProviderMemory(enabled bool) {
s.providerMemory = enabled
}
func NewService(pool *pgxpool.Pool, auditRepository *audit.Repository, redisClient *redis.Client, cursorCodec *pagination.Codec) *Service {
@@ -506,6 +513,32 @@ func (s *Service) WithdrawConsent(ctx context.Context, principal identity.Princi
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)
}
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
}
}
}
}
affectedConversations, err := s.stopConversationsAfterConsentWithdrawal(ctx, tx, access.tenantID, access.subjectID, result.Type)
if err != nil {
+57 -10
View File
@@ -169,11 +169,36 @@ func TestHouseholdSubjectConsentLifecycleAndIsolation(t *testing.T) {
if _, _, err := service.CreatePrivacyRequest(ctx, owner, subject.ID, "saas-integration-privacy-key", "saas-integration-privacy-conflict", conflictingPrivacy); !errors.Is(err, ErrIdempotencyConflict) {
t.Fatalf("privacy idempotency conflict=%v", err)
}
correctedName := "集成长者-隐私更正"
correction, created, err := service.CreatePrivacyRequest(ctx, owner, subject.ID, "saas-integration-correction-key", "saas-integration-correction", CreatePrivacyRequestInput{
RequestType: "CORRECTION", Scope: map[string]any{"corrections": map[string]any{"preferred_name": correctedName}},
})
if err != nil || !created || correction.Status != "COMPLETED" || len(correction.Subtasks) != 1 || correction.Subtasks[0].Status != "COMPLETED" {
t.Fatalf("correction request=%+v created=%v err=%v", correction, created, err)
}
correctedSubject, err := service.GetSubject(ctx, owner, subject.ID)
if err != nil || correctedSubject.PreferredName != correctedName || correctedSubject.Version != subject.Version+1 {
t.Fatalf("corrected subject=%+v err=%v", correctedSubject, err)
}
regranted, err := service.GrantConsent(ctx, editor, subject.ID, "saas-integration-consent-regrant", grantInput)
if err != nil || !regranted.Effective {
t.Fatalf("regrant consent=%+v err=%v", regranted, err)
}
withdrawal, created, err := service.CreatePrivacyRequest(ctx, owner, subject.ID, "saas-integration-withdrawal-key", "saas-integration-withdrawal", CreatePrivacyRequestInput{
RequestType: "CONSENT_WITHDRAWAL", Scope: map[string]any{"consent_types": []string{ConsentStoryExtraction}},
})
if err != nil || !created || withdrawal.Status != "COMPLETED" || len(withdrawal.Subtasks) != 1 || withdrawal.Subtasks[0].Status != "COMPLETED" {
t.Fatalf("withdrawal request=%+v created=%v err=%v", withdrawal, created, err)
}
consents, err = service.ListConsents(ctx, editor, subject.ID)
if err != nil || len(consents) != 2 || consents[0].Effective {
t.Fatalf("consents after batch withdrawal=%+v err=%v", consents, err)
}
if _, err := service.ListPrivacyRequests(ctx, editor, subject.ID); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("editor privacy list error=%v", err)
}
privacyRequests, err := service.ListPrivacyRequests(ctx, owner, subject.ID)
if err != nil || len(privacyRequests) != 1 || privacyRequests[0].ID != privacyRequest.ID {
if err != nil || len(privacyRequests) != 3 {
t.Fatalf("privacy requests=%+v err=%v", privacyRequests, err)
}
if _, err := service.GetPrivacyRequest(ctx, outsider, privacyRequest.ID); !errors.Is(err, ErrAccessDenied) {
@@ -206,15 +231,15 @@ func TestHouseholdSubjectConsentLifecycleAndIsolation(t *testing.T) {
if err := pool.QueryRow(ctx, `SELECT count(*) FROM consent_snapshot WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id=$1::uuid)`, subject.ID).Scan(&snapshotCount); err != nil {
t.Fatal(err)
}
if snapshotCount != 2 {
t.Fatalf("snapshot count=%d, want 2", snapshotCount)
if snapshotCount != 4 {
t.Fatalf("snapshot count=%d, want 4", snapshotCount)
}
var auditCount int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM audit_event WHERE request_id LIKE 'saas-integration-%'`).Scan(&auditCount); err != nil {
t.Fatal(err)
}
if auditCount != 10 {
t.Fatalf("audit count=%d, want 10", auditCount)
if auditCount != 13 {
t.Fatalf("audit count=%d, want 13", auditCount)
}
_ = uuid.MustParse(household.ID)
@@ -503,9 +528,11 @@ func TestContentReviewAndMemoryLifecycle(t *testing.T) {
turnID, turnPublicID := uuid.NewString(), uuid.NewString()
storyID, storyPublicID := uuid.NewString(), uuid.NewString()
revisionID := uuid.NewString()
var factPublicID string
var memoryID string
defer func() {
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id LIKE 'content-integration-%'`)
_, _ = pool.Exec(ctx, `DELETE FROM content_review_signal WHERE resource_public_id=$1::uuid OR resource_public_id=$2::uuid`, storyPublicID, factPublicID)
if memoryID != "" {
_, _ = pool.Exec(ctx, `DELETE FROM outbox_event WHERE aggregate_id=(SELECT id FROM curated_memory WHERE public_id=$1::uuid)`, memoryID)
_, _ = pool.Exec(ctx, `DELETE FROM memory_index_entry WHERE memory_id=(SELECT id FROM curated_memory WHERE public_id=$1::uuid)`, memoryID)
@@ -513,6 +540,7 @@ func TestContentReviewAndMemoryLifecycle(t *testing.T) {
_, _ = pool.Exec(ctx, `DELETE FROM memory_version WHERE memory_id=(SELECT id FROM curated_memory WHERE public_id=$1::uuid)`, memoryID)
_, _ = pool.Exec(ctx, `DELETE FROM curated_memory WHERE public_id=$1::uuid`, memoryID)
}
_, _ = pool.Exec(ctx, `DELETE FROM fact_candidate_revision WHERE fact_candidate_id IN (SELECT id FROM fact_candidate WHERE story_id=$1::uuid)`, storyID)
_, _ = pool.Exec(ctx, `DELETE FROM fact_candidate WHERE story_id=$1::uuid`, storyID)
_, _ = pool.Exec(ctx, `DELETE FROM story_source WHERE story_revision_id IN (SELECT id FROM story_revision WHERE story_id=$1::uuid)`, storyID)
_, _ = pool.Exec(ctx, `UPDATE story_draft SET current_revision_id=NULL WHERE id=$1::uuid`, storyID)
@@ -580,10 +608,10 @@ func TestContentReviewAndMemoryLifecycle(t *testing.T) {
VALUES($1::uuid,$2::uuid,0,15,'subject')`, revisionID, turnID); err != nil {
t.Fatalf("insert story source: %v", err)
}
if _, err := pool.Exec(ctx, `
if err := pool.QueryRow(ctx, `
INSERT INTO fact_candidate(tenant_id,subject_id,story_id,fact_type,content,statement_basis,source_turn_ids,status,generation_version)
VALUES('10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401',$1::uuid,'occupation',$2,'subject_statement',$3::jsonb,'CANDIDATE',1)`,
storyID, "曾从事教师工作", sourceIDs); err != nil {
VALUES('10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401',$1::uuid,'occupation',$2,'subject_statement',$3::jsonb,'CANDIDATE',1)
RETURNING public_id::text`, storyID, "曾从事教师工作", sourceIDs).Scan(&factPublicID); err != nil {
t.Fatalf("insert fact candidate: %v", err)
}
stories, err := service.ListSubjectStories(ctx, owner, "10000000-0000-0000-0000-000000000411", "CANDIDATE")
@@ -594,12 +622,31 @@ func TestContentReviewAndMemoryLifecycle(t *testing.T) {
if err != nil || len(story.Sources) != 1 || story.Sources[0].TurnID != turnPublicID || len(story.Facts) != 1 {
t.Fatalf("story=%+v err=%v", story, err)
}
correctedFact := "曾在合成城市从事教师工作"
confirmedFactStatus := "CONFIRMED"
factErrorType := "PLACE_ERROR"
fact, err := service.UpdateFactCandidate(ctx, owner, storyPublicID, factPublicID, "content-integration-fact-confirm", 1, UpdateFactCandidateInput{
Content: &correctedFact, Status: &confirmedFactStatus, ErrorType: &factErrorType, Reason: "逐字核对长者原话",
})
if err != nil || fact.Content != correctedFact || fact.Status != "CONFIRMED" || fact.Version != 2 {
t.Fatalf("confirmed fact=%+v err=%v", fact, err)
}
var factRevisionCount, factSignalCount int
if err := pool.QueryRow(ctx, `SELECT (SELECT count(*) FROM fact_candidate_revision WHERE fact_candidate_id=(SELECT id FROM fact_candidate WHERE public_id=$1::uuid)),(SELECT count(*) FROM content_review_signal WHERE resource_type='fact' AND resource_public_id=$1::uuid)`, factPublicID).Scan(&factRevisionCount, &factSignalCount); err != nil || factRevisionCount != 2 || factSignalCount != 1 {
t.Fatalf("fact revisions=%d signals=%d err=%v", factRevisionCount, factSignalCount, err)
}
if _, err := service.UpdateFactCandidate(ctx, owner, storyPublicID, factPublicID, "content-integration-fact-stale", 1, UpdateFactCandidateInput{Reason: "旧页面提交"}); !errors.Is(err, ErrVersionConflict) {
t.Fatalf("stale fact review error=%v", err)
}
if _, err := service.UpdateFactCandidate(ctx, outsider, storyPublicID, factPublicID, "content-integration-fact-outsider", 2, UpdateFactCandidateInput{Reason: "越权提交"}); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("outsider fact review error=%v", err)
}
revisedContent := "受试者年轻时在合成城市任职教师,并明确这是本人陈述。"
story, err = service.ReviseStory(ctx, owner, storyPublicID, revisedContent, "人工核对原话", "content-integration-revise", 1)
story, err = service.ReviseStory(ctx, owner, storyPublicID, revisedContent, "人工核对原话", "content-integration-revise", 1, "PLACE_ERROR")
if err != nil || story.Status != "IN_REVIEW" || story.Version != 2 || story.RevisionNo != 2 || len(story.Sources) != 1 {
t.Fatalf("revised story=%+v err=%v", story, err)
}
if _, err := service.ReviseStory(ctx, owner, storyPublicID, "过期修改", "旧版本", "content-integration-stale", 1); !errors.Is(err, ErrVersionConflict) {
if _, err := service.ReviseStory(ctx, owner, storyPublicID, "过期修改", "旧版本", "content-integration-stale", 1, ""); !errors.Is(err, ErrVersionConflict) {
t.Fatalf("stale story revision error=%v", err)
}
story, memory, err := service.ConfirmStory(ctx, owner, storyPublicID, "来源与本人同意均已核验", "content-integration-confirm", 2)