package store import ( "bytes" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "errors" "fmt" "path/filepath" "git.ipao.vip/rogee/go-sip/internal/contract" "git.ipao.vip/rogee/go-sip/internal/tenant" ) var ErrUnidentifiableData = errors.New("rejected data identity cannot be verified") // RejectData saves private original bytes before committing an immutable outbox // rejection. The caller may acknowledge the input only after this succeeds. func (s *Store) RejectData(dispatcherID string, original []byte, cause error) error { var id struct { EventID string `json:"event_id"` EventType string `json:"event_type"` DispatcherID string `json:"dispatcher_id"` TenantID int64 `json:"tenant_id"` Payload json.RawMessage `json:"payload"` } if json.Unmarshal(original, &id) != nil || id.DispatcherID != dispatcherID || id.EventID == "" || len(id.EventID) > 255 { return ErrUnidentifiableData } if id.EventType != "task.control" && id.EventType != "call.execute" && id.EventType != "sip.config" { return ErrUnidentifiableData } // A structurally valid receipt is not an original command identity. Never // turn a bounced outbound receipt into another receipt. if contract.ValidateCurrent("mq", original) == nil { var envelope map[string]json.RawMessage if err := json.Unmarshal(original, &envelope); err != nil { return err } var payload map[string]json.RawMessage if err := json.Unmarshal(envelope["payload"], &payload); err != nil { return err } if _, receipt := payload["status"]; receipt { return ErrUnidentifiableData } } if id.EventType != "sip.config" { var known int if err := s.db.QueryRow(`SELECT count(*) FROM dispatcher_tasks WHERE dispatcher_id=? AND tenant_id=?`, dispatcherID, id.TenantID).Scan(&known); err != nil { return err } if id.TenantID <= 0 || known == 0 { return ErrUnidentifiableData } } if cause == nil { return errors.New("rejection requires an actual failure reason") } tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() var payload any = map[string]any{"status": "rejected", "reason_message": cause.Error()} if id.EventType == "task.control" { replayed, err := replayRejectedEvent(tx, dispatcherID, id.EventID, id.EventType, id.TenantID) if err != nil { return err } if replayed { return tx.Commit() } payload, err = rejectedControlPayload(tx, dispatcherID, id.TenantID, id.Payload, "invalid_data", cause.Error()) if err != nil { return err } } if id.EventType == "call.execute" { payload = map[string]any{"status": "rejected", "reason_message": cause.Error(), "reason_code": nil} } receipt := map[string]any{"event_id": id.EventID, "event_type": id.EventType, "dispatcher_id": dispatcherID, "payload": payload} if id.EventType != "sip.config" { receipt["tenant_id"] = id.TenantID } body, err := json.Marshal(receipt) if err != nil { return err } if err := contract.ValidateCurrent("mq", body); err != nil { return fmt.Errorf("rejection feedback violates current Schema: %w", err) } var failure *contract.ValidationFailure var invalidSource []byte if errors.As(cause, &failure) { invalidSource = failure.Raw } evidence, err := json.Marshal(struct { Original []byte InvalidSource []byte Feedback json.RawMessage }{original, invalidSource, body}) if err != nil { return err } sum := sha256.Sum256(evidence) dir := filepath.Join(filepath.Dir(s.path), "rejected-data") if err := persistPrivateData(dir, hex.EncodeToString(sum[:])+".json", evidence); err != nil { return fmt.Errorf("persist rejected-data evidence: %w", err) } route, err := tenant.ResultRoute(dispatcherID) if err != nil { return err } if id.EventType == "call.execute" { var admitted int if err := tx.QueryRow(`SELECT count(*) FROM dispatcher_inbox WHERE dispatcher_id=? AND event_id=?`, dispatcherID, id.EventID).Scan(&admitted); err != nil { return err } if admitted != 0 { return errors.New("structural rejection conflicts with an already durably admitted execution") } } var oldType string var old []byte err = tx.QueryRow(`SELECT event_type,body FROM dispatcher_outbox WHERE dispatcher_id=? AND event_type=? AND event_id=?`, dispatcherID, id.EventType, id.EventID).Scan(&oldType, &old) if errors.Is(err, sql.ErrNoRows) { _, err = tx.Exec(`INSERT INTO dispatcher_outbox(dispatcher_id,event_id,event_type,routing_key,body) VALUES(?,?,?,?,?)`, dispatcherID, id.EventID, id.EventType, route.BindingKey, body) } else if err == nil { if oldType != id.EventType || !bytes.Equal(old, body) { return errors.New("rejection conflicts with original event result") } _, err = tx.Exec(`UPDATE dispatcher_outbox SET confirmed=0,confirmed_at=NULL WHERE dispatcher_id=? AND event_type=? AND event_id=?`, dispatcherID, id.EventType, id.EventID) } if err != nil { return fmt.Errorf("persist rejected-data feedback: %w", err) } return tx.Commit() }