This commit is contained in:
@@ -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(¤tRevisionID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `INSERT INTO story_revision(story_id,revision_no,content,author_type,author_id,edit_reason,base_revision_id) 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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user