feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s

Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
This commit is contained in:
Codex Local
2026-10-08 10:06:44 +08:00
parent 98fb2965d0
commit bf1233d5df
861 changed files with 94614 additions and 3209 deletions
+129 -18
View File
@@ -11,6 +11,7 @@ import (
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/callmemory"
"data-terminal-cloud/internal/database"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/outbox"
@@ -56,12 +57,17 @@ type ConversationContentSummary struct {
}
type StorySource struct {
TurnID string `json:"turn_id"`
SequenceNo int64 `json:"sequence_no"`
Speaker string `json:"speaker"`
Text string `json:"text"`
StartOffset *int `json:"start_offset,omitempty"`
EndOffset *int `json:"end_offset,omitempty"`
TurnID string `json:"turn_id"`
CallSessionID string `json:"call_session_id,omitempty"`
ImportID string `json:"import_id,omitempty"`
PrivateSources bool `json:"-"`
UtteranceID string `json:"utterance_id,omitempty"`
SourceRevision *int64 `json:"source_revision,omitempty"`
SequenceNo int64 `json:"sequence_no"`
Speaker string `json:"speaker"`
Text string `json:"text"`
StartOffset *int `json:"start_offset,omitempty"`
EndOffset *int `json:"end_offset,omitempty"`
}
type FactCandidate struct {
@@ -88,7 +94,10 @@ type UpdateFactCandidateInput struct {
type Story struct {
ID string `json:"id"`
SubjectID string `json:"subject_id"`
ConversationID string `json:"conversation_id"`
ConversationID string `json:"conversation_id,omitempty"`
CallSessionID string `json:"call_session_id,omitempty"`
ImportID string `json:"import_id,omitempty"`
PrivateSources bool `json:"-"`
Title string `json:"title"`
Content string `json:"content"`
Status string `json:"status"`
@@ -251,7 +260,9 @@ func (s *Service) ListSubjectStories(ctx context.Context, principal identity.Pri
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, storySelectSQL+` WHERE sd.tenant_id=$1::uuid AND sd.subject_id=$2::uuid AND (NULLIF($3,'') IS NULL OR sd.status=$3) ORDER BY sd.created_at DESC,sd.public_id`, access.tenantID, access.subjectID, status)
callAccess, callAccessErr := callmemory.ResolveAccess(ctx, s.pool, principal, subjectPublicID, false)
canReadCallSources := callAccessErr == nil && callAccess.Sensitive
rows, err := s.pool.Query(ctx, storySelectSQL+` WHERE sd.tenant_id=$1::uuid AND sd.subject_id=$2::uuid AND call_story_available(sd.id) AND (NULLIF($3,'') IS NULL OR sd.status=$3) ORDER BY sd.created_at DESC,sd.public_id`, access.tenantID, access.subjectID, status)
if err != nil {
return nil, fmt.Errorf("list subject stories: %w", err)
}
@@ -262,6 +273,9 @@ func (s *Service) ListSubjectStories(ctx context.Context, principal identity.Pri
if err != nil {
return nil, err
}
if item.PrivateSources && !canReadCallSources {
continue
}
item.Sources = []StorySource{}
item.Facts = []FactCandidate{}
items = append(items, item)
@@ -384,7 +398,7 @@ func (s *Service) ReviseStory(ctx context.Context, principal identity.Principal,
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 {
if _, err := tx.Exec(ctx, `INSERT INTO story_source(story_revision_id,conversation_turn_id,call_session_id,call_utterance_id,call_source_revision,import_turn_id,source_start_offset,source_end_offset,source_role) SELECT $1::uuid,conversation_turn_id,call_session_id,call_utterance_id,call_source_revision,import_turn_id,source_start_offset,source_end_offset,source_role FROM story_source WHERE story_revision_id=$2::uuid`, newRevisionID, currentRevisionID); err != nil {
return err
}
row := tx.QueryRow(ctx, storyUpdateReturningSQL, `IN_REVIEW`, newRevisionID, access.userID, reason, storyPublicID)
@@ -452,6 +466,10 @@ func (s *Service) ConfirmStory(ctx context.Context, principal identity.Principal
if err != nil {
return err
}
grant, e := callmemory.ResolveAccess(ctx, tx, principal, current.SubjectID, true)
if e != nil || (!grant.Sensitive && current.Sensitivity != "normal") {
return ErrAccessDenied
}
if current.Version != expectedVersion {
return ErrVersionConflict
}
@@ -478,6 +496,43 @@ func (s *Service) ConfirmStory(ctx context.Context, principal identity.Principal
return err
}
sourceRefs := []map[string]any{{"type": "story", "story_id": storyPublicID, "revision_no": story.RevisionNo}}
if story.PrivateSources {
rows, err := tx.Query(ctx, `SELECT ss.call_session_id::text,ss.call_utterance_id,ss.call_source_revision FROM story_source ss JOIN story_draft sd ON sd.current_revision_id=ss.story_revision_id WHERE sd.public_id=$1::uuid AND ss.call_session_id IS NOT NULL ORDER BY ss.call_utterance_id`, storyPublicID)
if err != nil {
return err
}
for rows.Next() {
var sessionID, utteranceID string
var sourceRevision int64
if err := rows.Scan(&sessionID, &utteranceID, &sourceRevision); err != nil {
rows.Close()
return err
}
sourceRefs = append(sourceRefs, map[string]any{"type": "call_utterance", "session_id": sessionID, "utterance_id": utteranceID, "source_revision": sourceRevision})
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
}
importRows, err := tx.Query(ctx, `SELECT it.import_id::text,it.id::text FROM story_source ss JOIN story_draft sd ON sd.current_revision_id=ss.story_revision_id JOIN memoir_import_turn it ON it.id=ss.import_turn_id WHERE sd.public_id=$1::uuid ORDER BY it.sequence_no`, storyPublicID)
if err != nil {
return err
}
for importRows.Next() {
var importID, turnID string
if err := importRows.Scan(&importID, &turnID); err != nil {
importRows.Close()
return err
}
sourceRefs = append(sourceRefs, map[string]any{"type": "import_turn", "import_id": importID, "turn_id": turnID})
}
err = importRows.Err()
importRows.Close()
if err != nil {
return err
}
sourceJSON, _ := json.Marshal(sourceRefs)
digest := sha256.Sum256([]byte(story.Content))
var memoryID, memoryVersionID string
@@ -534,6 +589,10 @@ func (s *Service) ConfirmStory(ctx context.Context, principal identity.Principal
}
func (s *Service) ListSubjectMemories(ctx context.Context, principal identity.Principal, subjectPublicID, query string) ([]CuratedMemory, error) {
grant, grantErr := callmemory.ResolveAccess(ctx, s.pool, principal, subjectPublicID, false)
if grantErr != nil {
return nil, ErrAccessDenied
}
access, _, err := resolveSubject(ctx, s.pool, principal, subjectPublicID, identity.CapabilitySubjectRead, false)
if err != nil {
return nil, err
@@ -550,11 +609,17 @@ func (s *Service) ListSubjectMemories(ctx context.Context, principal identity.Pr
return nil, err
}
items = append(items, item)
if !grant.Sensitive && item.Sensitivity != "normal" {
items = items[:len(items)-1]
}
}
return items, rows.Err()
}
func (s *Service) GetMemory(ctx context.Context, principal identity.Principal, memoryPublicID string) (CuratedMemory, error) {
if err := s.checkMemoryGrant(ctx, s.pool, principal, memoryPublicID, false); err != nil {
return CuratedMemory{}, err
}
result, err := scanMemory(s.pool.QueryRow(ctx, memorySelectSQL+` JOIN household h ON h.id=sp.household_id AND h.tenant_id=sp.tenant_id JOIN household_member hm ON hm.household_id=h.id AND hm.status='active' JOIN user_account member_account ON member_account.id=hm.user_id AND member_account.tenant_id=h.tenant_id AND member_account.status='active' WHERE cm.public_id=$1::uuid AND sp.status IN ('active','suspended') AND member_account.oidc_issuer=$2 AND member_account.oidc_subject=$3`, memoryPublicID, principal.Issuer, principal.Subject))
if errors.Is(err, pgx.ErrNoRows) {
return CuratedMemory{}, ErrAccessDenied
@@ -570,6 +635,9 @@ func (s *Service) UpdateMemory(ctx context.Context, principal identity.Principal
return CuratedMemory{}, ErrReviewStateConflict
}
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
if err := s.checkMemoryGrant(ctx, tx, principal, memoryPublicID, true); err != nil {
return err
}
var internalID, tenantID, subjectID, userID, userPublicID, role string
var currentVersionID, currentContent, currentStatus string
var currentVersionNo, resourceVersion int64
@@ -701,6 +769,9 @@ func (s *Service) UpdateMemory(ctx context.Context, principal identity.Principal
func (s *Service) RequestMemoryDeletion(ctx context.Context, principal identity.Principal, memoryPublicID, reason, requestID string, expectedVersion int64) (CuratedMemory, error) {
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
if err := s.checkMemoryGrant(ctx, tx, principal, memoryPublicID, true); err != nil {
return err
}
var internalID, tenantID, userPublicID, role, status string
var version int64
err := tx.QueryRow(ctx, `
@@ -799,7 +870,21 @@ func (s *Service) resolveConversationContent(ctx context.Context, q queryRow, p
return a, conversationID, nil
}
func (s *Service) resolveStory(ctx context.Context, q queryRow, p identity.Principal, publicID string, cap identity.Capability, lock bool) (contentAccess, Story, error) {
sql := storySelectSQL + ` JOIN household h ON h.id=sp.household_id AND h.tenant_id=sp.tenant_id JOIN household_member hm ON hm.household_id=h.id AND hm.status='active' JOIN user_account member_account ON member_account.id=hm.user_id AND member_account.tenant_id=h.tenant_id AND member_account.status='active' WHERE sd.public_id=$1::uuid AND sp.status IN ('active','suspended') AND member_account.oidc_issuer=$2 AND member_account.oidc_subject=$3`
if lock {
access, current, err := s.resolveStory(ctx, q, p, publicID, cap, false)
if err != nil {
return contentAccess{}, Story{}, err
}
if current.PrivateSources {
// Serialize all draft edits with extraction and source deletion, using
// the same subject-before-story lock order as memory mutations.
var epoch int64
if err := q.QueryRow(ctx, `SELECT privacy_epoch FROM memory_user_state WHERE subject_id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, access.subjectID, access.tenantID).Scan(&epoch); err != nil {
return contentAccess{}, Story{}, err
}
}
}
sql := storySelectSQL + ` JOIN household h ON h.id=sp.household_id AND h.tenant_id=sp.tenant_id JOIN household_member hm ON hm.household_id=h.id AND hm.status='active' JOIN user_account member_account ON member_account.id=hm.user_id AND member_account.tenant_id=h.tenant_id AND member_account.status='active' WHERE sd.public_id=$1::uuid AND call_story_available(sd.id) AND sp.status IN ('active','suspended') AND member_account.oidc_issuer=$2 AND member_account.oidc_subject=$3`
if lock {
sql += ` FOR UPDATE OF sd`
}
@@ -822,15 +907,31 @@ func (s *Service) resolveStory(ctx context.Context, q queryRow, p identity.Princ
if !identity.Can(a.role, cap) {
return contentAccess{}, Story{}, ErrAccessDenied
}
if story.PrivateSources {
if lock {
var available bool
if err := q.QueryRow(ctx, `SELECT call_story_available(id) FROM story_draft WHERE public_id=$1::uuid`, publicID).Scan(&available); err != nil {
return contentAccess{}, Story{}, err
}
if !available {
return contentAccess{}, Story{}, ErrAccessDenied
}
}
grant, err := callmemory.ResolveAccess(ctx, q, p, story.SubjectID, cap == identity.CapabilityStoryReview)
if err != nil || !grant.Sensitive {
return contentAccess{}, Story{}, ErrAccessDenied
}
}
return a, story, nil
}
const storySelectSQL = `SELECT sd.public_id::text AS story_public_id,sp.public_id::text AS subject_public_id,c.public_id::text AS conversation_public_id,sd.title,sr.content,sd.status,sd.confidence,sd.sensitivity,sd.generation_version,sr.revision_no,sr.author_type,COALESCE(sd.review_reason,''),sd.version,sd.created_at,sd.updated_at,sd.tenant_id::text AS tenant_internal_id,sd.subject_id::text AS subject_internal_id FROM story_draft sd JOIN subject_profile sp ON sp.id=sd.subject_id AND sp.tenant_id=sd.tenant_id JOIN conversation c ON c.id=sd.conversation_id AND c.tenant_id=sd.tenant_id JOIN story_revision sr ON sr.id=sd.current_revision_id`
const storyStatusReturningSQL = `UPDATE story_draft sd SET status=$1,reviewed_by=$2::uuid,reviewed_at=now(),review_reason=$3,version=sd.version+1,updated_at=now() FROM subject_profile sp,conversation c,story_revision sr WHERE sd.public_id=$4::uuid AND sp.id=sd.subject_id AND c.id=sd.conversation_id AND sr.id=sd.current_revision_id RETURNING sd.public_id::text,sp.public_id::text,c.public_id::text,sd.title,sr.content,sd.status,sd.confidence,sd.sensitivity,sd.generation_version,sr.revision_no,sr.author_type,COALESCE(sd.review_reason,''),sd.version,sd.created_at,sd.updated_at,sd.tenant_id::text,sd.subject_id::text`
const storyUpdateReturningSQL = `UPDATE story_draft sd SET status=$1,current_revision_id=$2::uuid,reviewed_by=$3::uuid,reviewed_at=now(),review_reason=$4,version=sd.version+1,updated_at=now() FROM subject_profile sp,conversation c,story_revision sr WHERE sd.public_id=$5::uuid AND sp.id=sd.subject_id AND c.id=sd.conversation_id AND sr.id=$2::uuid RETURNING sd.public_id::text,sp.public_id::text,c.public_id::text,sd.title,sr.content,sd.status,sd.confidence,sd.sensitivity,sd.generation_version,sr.revision_no,sr.author_type,COALESCE(sd.review_reason,''),sd.version,sd.created_at,sd.updated_at,sd.tenant_id::text,sd.subject_id::text`
const storySelectSQL = `SELECT sd.public_id::text AS story_public_id,sp.public_id::text AS subject_public_id,COALESCE(c.public_id::text,'') AS conversation_public_id,COALESCE(sd.call_session_id::text,''),COALESCE(sd.import_id::text,''),EXISTS(SELECT 1 FROM story_source ps WHERE ps.story_revision_id=sd.current_revision_id AND (ps.call_session_id IS NOT NULL OR ps.import_turn_id IS NOT NULL)),sd.title,sr.content,sd.status,sd.confidence,sd.sensitivity,sd.generation_version,sr.revision_no,sr.author_type,COALESCE(sd.review_reason,''),sd.version,sd.created_at,sd.updated_at,sd.tenant_id::text AS tenant_internal_id,sd.subject_id::text AS subject_internal_id FROM story_draft sd JOIN subject_profile sp ON sp.id=sd.subject_id AND sp.tenant_id=sd.tenant_id LEFT JOIN conversation c ON c.id=sd.conversation_id AND c.tenant_id=sd.tenant_id JOIN story_revision sr ON sr.id=sd.current_revision_id`
const storyReturningColumns = `sd.public_id::text,sp.public_id::text,COALESCE((SELECT c.public_id::text FROM conversation c WHERE c.id=sd.conversation_id),''),COALESCE(sd.call_session_id::text,''),COALESCE(sd.import_id::text,''),EXISTS(SELECT 1 FROM story_source ps WHERE ps.story_revision_id=sd.current_revision_id AND (ps.call_session_id IS NOT NULL OR ps.import_turn_id IS NOT NULL)),sd.title,sr.content,sd.status,sd.confidence,sd.sensitivity,sd.generation_version,sr.revision_no,sr.author_type,COALESCE(sd.review_reason,''),sd.version,sd.created_at,sd.updated_at,sd.tenant_id::text,sd.subject_id::text`
const storyStatusReturningSQL = `UPDATE story_draft sd SET status=$1,reviewed_by=$2::uuid,reviewed_at=now(),review_reason=$3,version=sd.version+1,updated_at=now() FROM subject_profile sp,story_revision sr WHERE sd.public_id=$4::uuid AND sp.id=sd.subject_id AND sr.id=sd.current_revision_id AND call_story_available(sd.id) RETURNING ` + storyReturningColumns
const storyUpdateReturningSQL = `UPDATE story_draft sd SET status=$1,current_revision_id=$2::uuid,reviewed_by=$3::uuid,reviewed_at=now(),review_reason=$4,version=sd.version+1,updated_at=now() FROM subject_profile sp,story_revision sr WHERE sd.public_id=$5::uuid AND sp.id=sd.subject_id AND sr.id=$2::uuid AND call_story_available(sd.id) RETURNING ` + storyReturningColumns
func storyScanTargets(s *Story, tenantID, subjectInternal *string) []any {
return []any{&s.ID, &s.SubjectID, &s.ConversationID, &s.Title, &s.Content, &s.Status, &s.Confidence, &s.Sensitivity, &s.GenerationVersion, &s.RevisionNo, &s.AuthorType, &s.ReviewReason, &s.Version, &s.CreatedAt, &s.UpdatedAt, tenantID, subjectInternal}
return []any{&s.ID, &s.SubjectID, &s.ConversationID, &s.CallSessionID, &s.ImportID, &s.PrivateSources, &s.Title, &s.Content, &s.Status, &s.Confidence, &s.Sensitivity, &s.GenerationVersion, &s.RevisionNo, &s.AuthorType, &s.ReviewReason, &s.Version, &s.CreatedAt, &s.UpdatedAt, tenantID, subjectInternal}
}
func scanStory(row rowScanner) (Story, error) {
var s Story
@@ -839,21 +940,31 @@ func scanStory(row rowScanner) (Story, error) {
return s, err
}
func (s *Service) loadStoryChildren(ctx context.Context, story *Story) error {
rows, err := s.pool.Query(ctx, `SELECT ct.public_id::text,ct.sequence_no,ct.speaker,ct.final_text,ss.source_start_offset,ss.source_end_offset FROM story_draft sd JOIN story_source ss ON ss.story_revision_id=sd.current_revision_id JOIN conversation_turn ct ON ct.id=ss.conversation_turn_id WHERE sd.public_id=$1::uuid ORDER BY ct.sequence_no`, story.ID)
rows, err := s.pool.Query(ctx, `SELECT COALESCE(ct.public_id::text,u.utterance_id,it.id::text),COALESCE(ss.call_session_id::text,''),COALESCE(ss.call_utterance_id,''),ss.call_source_revision,
COALESCE(ct.sequence_no,it.sequence_no,row_number() OVER(ORDER BY u.source_at,u.utterance_id)),ss.source_role,COALESCE(ct.final_text,u.text_content,it.content),ss.source_start_offset,ss.source_end_offset
FROM story_draft sd JOIN story_source ss ON ss.story_revision_id=sd.current_revision_id
LEFT JOIN conversation_turn ct ON ct.id=ss.conversation_turn_id
LEFT JOIN call_utterance u ON u.session_id=ss.call_session_id AND u.utterance_id=ss.call_utterance_id AND u.source_revision=ss.call_source_revision
LEFT JOIN memoir_import_turn it ON it.id=ss.import_turn_id
WHERE sd.public_id=$1::uuid AND call_story_available(sd.id) ORDER BY ct.sequence_no,u.source_at,u.utterance_id`, story.ID)
if err != nil {
return err
}
story.Sources = []StorySource{}
for rows.Next() {
var x StorySource
if err := rows.Scan(&x.TurnID, &x.SequenceNo, &x.Speaker, &x.Text, &x.StartOffset, &x.EndOffset); err != nil {
if err := rows.Scan(&x.TurnID, &x.CallSessionID, &x.UtteranceID, &x.SourceRevision, &x.SequenceNo, &x.Speaker, &x.Text, &x.StartOffset, &x.EndOffset); err != nil {
rows.Close()
return err
}
story.Sources = append(story.Sources, x)
}
err = rows.Err()
rows.Close()
rows, err = s.pool.Query(ctx, `SELECT fc.public_id::text,fc.fact_type,fc.content,fc.statement_basis,fc.confidence,fc.sensitivity,fc.source_turn_ids,fc.status,fc.version FROM fact_candidate fc JOIN story_draft sd ON sd.id=fc.story_id WHERE sd.public_id=$1::uuid ORDER BY fc.created_at,fc.public_id`, story.ID)
if err != nil {
return err
}
rows, err = s.pool.Query(ctx, `SELECT fc.public_id::text,fc.fact_type,fc.content,fc.statement_basis,fc.confidence,fc.sensitivity,fc.source_turn_ids,fc.status,fc.version FROM fact_candidate fc JOIN story_draft sd ON sd.id=fc.story_id WHERE sd.public_id=$1::uuid AND call_story_available(sd.id) ORDER BY fc.created_at,fc.public_id`, story.ID)
if err != nil {
return err
}
@@ -877,7 +988,7 @@ func scanFactCandidate(row rowScanner, result *FactCandidate) error {
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'`
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,COALESCE(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 LEFT 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