// Package calllog writes redacted, durable business facts for outbound calls. // It never writes the original phone number, credentials, audio, prompts, or // provider URLs. The phone reference is a keyed digest so one number can be // correlated across tasks without making the log a contact database. package calllog import ( "crypto/hmac" cryptorand "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "os" "path/filepath" "regexp" "strings" "sync" "time" ) var phonePattern = regexp.MustCompile(`^[0-9]{3,32}$`) var eventTypePattern = regexp.MustCompile(`^[a-z][a-z0-9_.-]{0,63}$`) // Identity is the only phone identity exposed to log records. type Identity struct { Ref string Mask string } // Event is the allow-listed input to Logger.Append. Phone is consumed to make // a keyed identity and is never serialized. type Event struct { EventType string Phone string PhoneRef string PhoneMask string EventID string OccurredAt time.Time TenantID string TraceID string ExecutionID string TaskID string TaskItemID string TaskRevision int64 AttemptID string CallID string AgentID string CellID string RoutePolicyID string CallerProfileID string TrunkID string CallState string AttemptState string SIPStage string SIPStatusCode int SIPReason string Status string Result string ReasonCode string RecordingID string RecordingState string RecordingSize int64 RecordingDuration int64 RecordingSHA256 string DurationMS int64 } type record struct { SchemaVersion string `json:"schema_version"` EventID string `json:"event_id"` OccurredAt string `json:"occurred_at"` EventType string `json:"event_type"` PhoneRef string `json:"phone_ref"` PhoneMask string `json:"phone_mask"` TenantID string `json:"tenant_id,omitempty"` TraceID string `json:"trace_id,omitempty"` ExecutionID string `json:"execution_id,omitempty"` TaskID string `json:"task_id,omitempty"` TaskItemID string `json:"task_item_id,omitempty"` TaskRevision int64 `json:"task_revision,omitempty"` AttemptID string `json:"attempt_id,omitempty"` CallID string `json:"call_id,omitempty"` AgentID string `json:"agent_id,omitempty"` CellID string `json:"cell_id,omitempty"` RoutePolicyID string `json:"route_policy_id,omitempty"` CallerProfileID string `json:"caller_profile_id,omitempty"` TrunkID string `json:"trunk_id,omitempty"` CallState string `json:"call_state,omitempty"` AttemptState string `json:"attempt_state,omitempty"` SIPStage string `json:"sip_stage,omitempty"` SIPStatusCode int `json:"sip_status_code,omitempty"` SIPReason string `json:"sip_reason,omitempty"` Status string `json:"status,omitempty"` Result string `json:"result,omitempty"` ReasonCode string `json:"reason_code,omitempty"` RecordingID string `json:"recording_id,omitempty"` RecordingState string `json:"recording_state,omitempty"` RecordingSize int64 `json:"recording_size_bytes,omitempty"` RecordingDuration int64 `json:"recording_duration_ms,omitempty"` RecordingSHA256 string `json:"recording_sha256,omitempty"` DurationMS int64 `json:"duration_ms,omitempty"` } // Logger appends one JSON object per line. A mutex and Sync keep concurrent // call updates from interleaving and make a successful append durable enough // for the Agent's file-backed recovery boundary. type Logger struct { path string key []byte now func() time.Time mu sync.Mutex } func New(path string, key []byte, now func() time.Time) (*Logger, error) { if strings.TrimSpace(path) == "" { return nil, errors.New("call log path is required") } if len(key) < 16 { return nil, errors.New("call log phone key must contain at least 16 bytes") } if now == nil { now = time.Now } return &Logger{path: path, key: append([]byte(nil), key...), now: now}, nil } func (l *Logger) Path() string { return l.path } // Identity returns the stable, redacted reference for a contract callee. func (l *Logger) Identity(phone string) (Identity, error) { if err := validatePhone(phone); err != nil { return Identity{}, err } mac := hmac.New(sha256.New, l.key) _, _ = mac.Write([]byte(phone)) return Identity{Ref: "hmac-sha256:" + hex.EncodeToString(mac.Sum(nil)), Mask: maskPhone(phone)}, nil } func (l *Logger) Append(event Event) error { if l == nil { return errors.New("call logger is nil") } if !eventTypePattern.MatchString(event.EventType) { return errors.New("event type is invalid") } identity := Identity{Ref: event.PhoneRef, Mask: event.PhoneMask} if event.Phone != "" { computed, err := l.Identity(event.Phone) if err != nil { return err } if identity.Ref != "" && identity.Ref != computed.Ref { return errors.New("phone reference does not match phone") } identity = computed } if err := validateIdentity(identity); err != nil { return err } if event.SIPStatusCode != 0 && (event.SIPStatusCode < 100 || event.SIPStatusCode > 699) { return errors.New("SIP status code is invalid") } if event.TaskRevision < 0 || event.DurationMS < 0 || event.RecordingSize < 0 || event.RecordingDuration < 0 { return errors.New("negative business log value") } if len(event.RecordingSHA256) > 0 && (len(event.RecordingSHA256) != 64 || !isLowerHex(event.RecordingSHA256)) { return errors.New("recording SHA-256 is invalid") } occurredAt := event.OccurredAt if occurredAt.IsZero() { occurredAt = l.now() } eventID := event.EventID if eventID == "" { var random [16]byte if _, err := cryptorand.Read(random[:]); err != nil { return fmt.Errorf("generate call log event ID: %w", err) } eventID = hex.EncodeToString(random[:]) } value := record{ SchemaVersion: "1.0", EventID: eventID, OccurredAt: occurredAt.UTC().Format(time.RFC3339Nano), EventType: event.EventType, PhoneRef: identity.Ref, PhoneMask: identity.Mask, TenantID: event.TenantID, TraceID: event.TraceID, ExecutionID: event.ExecutionID, TaskID: event.TaskID, TaskItemID: event.TaskItemID, TaskRevision: event.TaskRevision, AttemptID: event.AttemptID, CallID: event.CallID, AgentID: event.AgentID, CellID: event.CellID, RoutePolicyID: event.RoutePolicyID, CallerProfileID: event.CallerProfileID, TrunkID: event.TrunkID, CallState: event.CallState, AttemptState: event.AttemptState, SIPStage: event.SIPStage, SIPStatusCode: event.SIPStatusCode, SIPReason: event.SIPReason, Status: event.Status, Result: event.Result, ReasonCode: event.ReasonCode, RecordingID: event.RecordingID, RecordingState: event.RecordingState, RecordingSize: event.RecordingSize, RecordingDuration: event.RecordingDuration, RecordingSHA256: event.RecordingSHA256, DurationMS: event.DurationMS, } data, err := json.Marshal(value) if err != nil { return fmt.Errorf("encode call log event: %w", err) } l.mu.Lock() defer l.mu.Unlock() if err := os.MkdirAll(filepath.Dir(l.path), 0o700); err != nil { return fmt.Errorf("create call log directory: %w", err) } file, err := os.OpenFile(l.path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) if err != nil { return fmt.Errorf("open call log: %w", err) } if err := file.Chmod(0o600); err != nil { _ = file.Close() return fmt.Errorf("protect call log: %w", err) } if _, err := file.Write(append(data, '\n')); err != nil { _ = file.Close() return fmt.Errorf("append call log: %w", err) } if err := file.Sync(); err != nil { _ = file.Close() return fmt.Errorf("sync call log: %w", err) } if err := file.Close(); err != nil { return fmt.Errorf("close call log: %w", err) } return nil } func validateIdentity(identity Identity) error { if !strings.HasPrefix(identity.Ref, "hmac-sha256:") || len(identity.Ref) != len("hmac-sha256:")+64 || !isLowerHex(strings.TrimPrefix(identity.Ref, "hmac-sha256:")) { return errors.New("redacted phone reference is invalid") } if len(identity.Mask) < 3 || strings.Contains(identity.Mask, " ") || strings.ContainsAny(identity.Mask, "\r\n") { return errors.New("redacted phone mask is invalid") } for _, char := range identity.Mask { if char != '*' && (char < '0' || char > '9') { return errors.New("redacted phone mask is invalid") } } if !strings.Contains(identity.Mask, "*") { return errors.New("redacted phone mask must hide digits") } return nil } func validatePhone(phone string) error { if !phonePattern.MatchString(phone) { return errors.New("phone must be the original 3-32 digit callee") } return nil } func maskPhone(phone string) string { if len(phone) <= 4 { return strings.Repeat("*", len(phone)) } return strings.Repeat("*", len(phone)-4) + phone[len(phone)-4:] } func isLowerHex(value string) bool { for _, char := range value { if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f')) { return false } } return true }