# REDA outbound (participante indireto) Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (or superpowers:subagent-driven-development) to implement this plan task-by-task. Use superpowers:test-driven-development for every task: write the failing test, see it fail, implement minimal code, see it pass, commit.

**Goal:** Build the full operational REDA outbound surface so Monetarie can register (reda.014), update responsible parties (reda.022) and deregister (reda.031) the indirect participants it represents at BACEN, reading the async reda.016 response and reflecting real state in an admin UI.

**Architecture:** New `monetarie_settlement.indirect_participants` table + `Shared.Reda` context drive an admin REST surface in the `settlement_service` gateway. Outbound REDA reuses the proven signed SPI send path (XMLDSig at the HSM, fail-closed, secondary/CSM channel, persisted in `bacen_outbound`). The existing reda.016 inbound handler is extended to correlate by `original_msg_id` and transition row status. A Vue admin UI (list + detail) drives it, fully i18n.

**Tech Stack:** Elixir umbrella (apps/shared, apps/spi_service, apps/settlement_service) + Phoenix; Ecto/PostgreSQL; Vue 3 + axios + PrimeVue + vue-i18n; ExUnit + vitest.

**Workspace:** worktree `~/.config/superpowers/worktrees/monetarie/reda-outbound`, branch `feat/reda-outbound-indirect-participants`. Backend = `pix/backend`; frontend = `pix/frontend/admin`.

**Design:** `docs/plans/2026-06-29-reda-outbound-indirect-participants-design.md`.

**Guard-rails (do not violate):**
- REDA must be signed (XMLDSig, fail-closed). Reuse the pacs.008/camt.060 signed path; do not modify it, do not use an unsigned generic dispatch.
- pt-br correct accents, NO AI em-dash, every string in the UI via i18n (zero hardcode).
- No secrets/tokens in logs or commits. Live send to BACEN homolog ONLY with explicit owner authorization.
- Commits are frequent and scoped. Do not commit screenshots with JWT/PII, or `.serena/`.

**Pre-flight (once, before Task 1):**
- `cd pix/backend && mix deps.get` then `mix compile` (expect success with pre-existing warnings).
- `cd pix/frontend/admin && pnpm install`.
- Note: full `mix test` is blocked locally by DB auth (FATAL 28P01); run FOCUSED ExUnit files only (each task names them). If a focused test needs the DB and 28P01 blocks it, record it and rely on XSD/pure-function tests + homolog verification, do not treat as a code failure.

---

## Phase A: Backend data + domain

### Task 1: Migration for `indirect_participants`

**Files:**
- Read first (mirror location + Repo + prefix): the participants migration and schema — `apps/shared/lib/shared/schemas/spi/participant.ex` (prefix `monetarie_settlement`) and find its migration under `apps/shared/priv/repo/migrations/` (grep `participants`). Place the new migration in the SAME migrations dir and confirm which Repo runs `monetarie_settlement`.
- Create: `apps/shared/priv/repo/migrations/<timestamp>_create_indirect_participants.exs`

**Step 1: Write the migration**

```elixir
defmodule Shared.Repo.Migrations.CreateIndirectParticipants do
  use Ecto.Migration

  def change do
    create table(:indirect_participants, prefix: "monetarie_settlement") do
      add :indirect_ispb, :string, null: false
      add :indirect_cnpj, :string, null: false
      add :name, :string, null: false
      add :status, :string, null: false, default: "PENDING"
      add :client_request_id, :string
      add :last_request_msg_id, :string
      add :last_response_msg_id, :string
      add :last_status_reason, :string
      add :director_name, :string
      add :director_cpf, :string
      add :director_phone, :string
      add :director_email, :string
      add :contact_phone, :string
      add :contact_email, :string
      add :tech_keyword, :string
      add :registered_at, :utc_datetime_usec
      add :deregistered_at, :utc_datetime_usec
      timestamps(type: :utc_datetime_usec)
    end

    create unique_index(:indirect_participants, [:indirect_ispb], prefix: "monetarie_settlement")
    create unique_index(:indirect_participants, [:client_request_id], prefix: "monetarie_settlement", where: "client_request_id IS NOT NULL", name: :indirect_participants_client_request_id_index)
    create index(:indirect_participants, [:last_request_msg_id], prefix: "monetarie_settlement")
    create index(:indirect_participants, [:status], prefix: "monetarie_settlement")
  end
end
```

**Step 2: Run it** (against the local DB if reachable; otherwise defer to the homolog one-off task at deploy). `cd pix/backend && mix ecto.migrate` for the owning repo. Expected: table created. If 28P01 blocks local, record and continue (the migration is verified at deploy).

**Step 3: Commit**
```bash
git add apps/shared/priv/repo/migrations/*_create_indirect_participants.exs
git commit -m "feat(reda): migration indirect_participants"
```

### Task 2: Ecto schema + changeset

**Files:**
- Read first: `apps/shared/lib/shared/schemas/spi/participant.ex` (mirror style/prefix).
- Create: `apps/shared/lib/shared/schemas/reda/indirect_participant.ex`
- Test: `apps/shared/test/shared/schemas/reda/indirect_participant_test.exs`

**Step 1: Write the failing test** (pure changeset, no DB)
```elixir
defmodule Shared.Schemas.Reda.IndirectParticipantTest do
  use ExUnit.Case, async: true
  alias Shared.Schemas.Reda.IndirectParticipant

  test "valid create changeset" do
    cs = IndirectParticipant.create_changeset(%IndirectParticipant{}, %{
      indirect_ispb: "12345678", indirect_cnpj: "12345678000199", name: "ACME Pagamentos"
    })
    assert cs.valid?
    assert get_field(cs, :status) == "PENDING"
  end

  test "rejects bad ispb length and bad cnpj" do
    cs = IndirectParticipant.create_changeset(%IndirectParticipant{}, %{indirect_ispb: "123", indirect_cnpj: "x", name: "A"})
    refute cs.valid?
    assert errors_on(cs)[:indirect_ispb]
    assert errors_on(cs)[:indirect_cnpj]
  end

  test "update_responsibles_changeset requires director_cpf 11 and tech_keyword 8 alnum" do
    cs = IndirectParticipant.update_responsibles_changeset(%IndirectParticipant{status: "ACTIVE"}, %{
      director_name: "Fulano", director_cpf: "123", director_phone: "11", director_email: "x",
      contact_phone: "11", contact_email: "y", tech_keyword: "ab"
    })
    refute cs.valid?
    assert errors_on(cs)[:director_cpf]
    assert errors_on(cs)[:tech_keyword]
    assert errors_on(cs)[:director_email]
  end
end
```
(Use the project's `errors_on/1`, `get_field/2` test helpers; if absent, inline `Ecto.Changeset.traverse_errors`.)

**Step 2: Run, expect fail** `mix test test/shared/schemas/reda/indirect_participant_test.exs` -> FAIL (module not defined).

**Step 3: Implement**
```elixir
defmodule Shared.Schemas.Reda.IndirectParticipant do
  use Ecto.Schema
  import Ecto.Changeset

  @schema_prefix "monetarie_settlement"
  @statuses ~w(PENDING ACTIVE REJECTED DEREGISTERING INACTIVE)

  schema "indirect_participants" do
    field :indirect_ispb, :string
    field :indirect_cnpj, :string
    field :name, :string
    field :status, :string, default: "PENDING"
    field :client_request_id, :string
    field :last_request_msg_id, :string
    field :last_response_msg_id, :string
    field :last_status_reason, :string
    field :director_name, :string
    field :director_cpf, :string
    field :director_phone, :string
    field :director_email, :string
    field :contact_phone, :string
    field :contact_email, :string
    field :tech_keyword, :string
    field :registered_at, :utc_datetime_usec
    field :deregistered_at, :utc_datetime_usec
    timestamps(type: :utc_datetime_usec)
  end

  @email ~r/^[^@\s]+@[^@\s]+\.[^@\s]+$/

  def create_changeset(struct, attrs) do
    struct
    |> cast(attrs, [:indirect_ispb, :indirect_cnpj, :name, :client_request_id])
    |> validate_required([:indirect_ispb, :indirect_cnpj, :name])
    |> validate_format(:indirect_ispb, ~r/^\d{8}$/)
    |> validate_format(:indirect_cnpj, ~r/^\d{14}$/)
    |> validate_length(:name, max: 100)
    |> put_change(:status, "PENDING")
    |> unique_constraint(:indirect_ispb)
    |> unique_constraint(:client_request_id, name: :indirect_participants_client_request_id_index)
  end

  def update_responsibles_changeset(struct, attrs) do
    struct
    |> cast(attrs, [:director_name, :director_cpf, :director_phone, :director_email, :contact_phone, :contact_email, :tech_keyword])
    |> validate_required([:director_name, :director_cpf, :director_phone, :director_email, :contact_phone, :contact_email, :tech_keyword])
    |> validate_format(:director_cpf, ~r/^\d{11}$/)
    |> validate_format(:tech_keyword, ~r/^[A-Za-z0-9]{8}$/)
    |> validate_format(:director_email, @email)
    |> validate_format(:contact_email, @email)
  end

  def status_changeset(struct, attrs) do
    struct
    |> cast(attrs, [:status, :last_request_msg_id, :last_response_msg_id, :last_status_reason, :registered_at, :deregistered_at])
    |> validate_inclusion(:status, @statuses)
  end
end
```

**Step 4: Run, expect pass.** **Step 5: Commit** `feat(reda): IndirectParticipant schema + changesets`.

### Task 3: `Shared.Reda` context

**Files:**
- Read first: `apps/shared/lib/shared/automation/scheduled_messages.ex` (or the module behind the scheduled-messages controller) for the context style + Repo alias.
- Create: `apps/shared/lib/shared/reda.ex`
- Test: `apps/shared/test/shared/reda_test.exs`

Functions: `list/1`, `get/1`, `get_by_request_msg_id/1`, `create/1` (idempotent on `client_request_id`), `mark_status/3`. (Sending is Task 4; this context only does persistence + transitions.)

**Step 1: failing test** (DB-backed; uses Sandbox). Cover: create returns PENDING; create twice with same client_request_id returns the same row (idempotent); mark_status ACTIVE sets registered_at. **Step 2** run -> fail. **Step 3** implement with Repo. **Step 4** pass. If 28P01 blocks DB locally, record + verify at deploy; keep the test. **Step 5** commit `feat(reda): Shared.Reda context`.

---

## Phase B: Backend signed send + builders

### Task 4: Reda sender (reuse the proven signed outbound path)

**Files:**
- Read first (CRITICAL SEAM): `apps/spi_service/lib/spi_service/workers/outbound_sender.ex` (around `sign_outbound_xml/2` ~249) and trace how an outbound message (e.g. pacs.008 or camt.060) is enqueued/sent end to end: who calls `MessageBuilder.build/2`, who calls `XmlSigner.sign_spi/2`, who POSTs via `Shared.Bacen.Client` on the routed channel, and who writes `bacen_outbound`. Identify the public seam to send an arbitrary already-built SPI message (a function or an Oban worker). Mirror EXACTLY; do not invent a new send path.
- Also read: `apps/shared/lib/shared/bacen/iso20022/message_builder.ex` build_reda014 (~659), build_reda022 (~1090), build_reda031 (~1305) and `build/2` (~56) to confirm the param keys.
- Create: `apps/shared/lib/shared/reda/sender.ex` (thin: maps a row + op into builder params, calls the proven signed-send seam, returns `{:ok, msg_id}` and stamps `last_request_msg_id`).
- Test: `apps/shared/test/shared/reda/builder_xsd_test.exs` (pure: build each reda + validate against XSD).

**Step 1: failing XSD test** (no network, proves the messages we will send are schema-valid)
```elixir
defmodule Shared.Reda.BuilderXsdTest do
  use ExUnit.Case, async: true
  alias Shared.Bacen.Iso20022.MessageBuilder
  # Reuse the project's existing XSD-validation helper used by other builder tests.
  # Find it: grep -rl "xmllint\|validate_xsd\|XsdValidator" apps/shared/test

  test "reda.014 builds and validates against v5.12.1 XSD" do
    xml = MessageBuilder.build("reda.014", %{indirect_ispb: "12345678", indirect_cnpj: "12345678000199"})
    assert {:ok, _} = validate_spi_xsd(xml, "reda.014")
  end

  test "reda.022 builds and validates" do
    xml = MessageBuilder.build("reda.022", %{party_ispb: "46026562", modifications: sample_mods()})
    assert {:ok, _} = validate_spi_xsd(xml, "reda.022")
  end

  test "reda.031 builds and validates" do
    xml = MessageBuilder.build("reda.031", %{target_ispb: "12345678"})
    assert {:ok, _} = validate_spi_xsd(xml, "reda.031")
  end
end
```
(Replace `validate_spi_xsd/2` + `sample_mods/0` with the real helper found in step "read first"; the existing builder/verify_spi tests already validate SPI XML against `apps/shared/priv/xsd/spi/v5.12.1` — mirror them.)

**Step 2** run -> fail (or red on helper). **Step 3** implement `Shared.Reda.Sender` that, per op, builds params:
- reda.014: `%{indirect_ispb: row.indirect_ispb, indirect_cnpj: row.indirect_cnpj}`
- reda.022: `%{party_ispb: row.indirect_ispb, modifications: mods_from_row(row)}` (contact/director/tech_address/cpf, shapes per message_builder.ex build_reda022_mod ~1160)
- reda.031: `%{target_ispb: row.indirect_ispb}`
then calls the proven signed-send seam and returns the generated `message_id`. **Step 4** pass. **Step 5** commit `feat(reda): signed outbound sender for reda.014/022/031`.

---

## Phase C: Backend correlation

### Task 5: Correlate reda.016 -> row status

**Files:**
- Modify: `apps/spi_service/lib/spi_service/workers/inbound_processor.ex` (`process_party_status_advice/2` ~1127): after the existing broadcast, look up `Shared.Reda.get_by_request_msg_id(message["original_msg_id"])`; if found, transition: success status -> ACTIVE (was PENDING from 014/022) or INACTIVE (was DEREGISTERING from 031); rejection -> REJECTED + `last_status_reason` from the status reason. Distinguish success vs rejection from the parsed `status`/reason (read how `message["status"]`/`StsRsn` is populated in message_parser.ex ~308-337; if reason present and status not success-code -> REJECTED).
- Test: `apps/spi_service/test/spi_service/workers/inbound_processor_reda_test.exs`

**Step 1: failing test** — seed a PENDING row with `last_request_msg_id = "RED..ABC"`; feed a reda.016 map with `original_msg_id: "RED..ABC"`, success status; assert row becomes ACTIVE + `last_response_msg_id` set. Second test: DEREGISTERING + success -> INACTIVE. Third: PENDING + rejection reason -> REJECTED + reason stored. **Step 2** fail. **Step 3** implement the lookup+transition (do not remove the existing broadcast). **Step 4** pass. **Step 5** commit `feat(reda): correlate reda.016 to indirect_participant status`.

---

## Phase D: Backend API

### Task 6: `RedaController` + routes + tests

**Files:**
- Read first: `apps/settlement_service/lib/settlement_service_web/controllers/admin/scheduled_messages_controller.ex` (canonical: RequirePermission plugs, send_problem, changeset errors) and `apps/settlement_service/lib/settlement_service_web/router.ex` (admin scope + pipeline `[:gateway, :api, :gateway_authenticated]`).
- Create: `apps/settlement_service/lib/settlement_service_web/controllers/admin/reda_controller.ex`
- Modify: `router.ex` (add 6 routes under the admin scope).
- Test: `apps/settlement_service/test/settlement_service_web/controllers/admin/reda_controller_test.exs`

Actions:
- `index` -> `Shared.Reda.list(opts)` (limit/offset/status filter) -> `{data, pagination}`.
- `show` -> get or `send_problem(:not_found, "participant_not_found")`.
- `create` -> validate, `Shared.Reda.create/1` (PENDING), `Reda.Sender.send(row, :reda014)`, stamp msg_id, return 201.
- `update` -> require ACTIVE, `update_responsibles_changeset`, persist, `Reda.Sender.send(row, :reda022)` (status stays ACTIVE; mark PENDING_UPDATE optional - keep simple: keep ACTIVE, store last_request_msg_id), return 200.
- `delete` -> require ACTIVE, set DEREGISTERING, `Reda.Sender.send(row, :reda031)`, return 200.
- `history` -> derive from `bacen_outbound` (by msg_ids on the row) + `bacen_inbound` (reda.016 by original_msg_id) joined, newest first.

**Step 1: failing controller tests** — 401 without auth; 422 on bad CNPJ; 201 happy path (mock/stub the Sender seam so no network; assert row PENDING + Sender called); delete on non-ACTIVE -> 409/422. **Step 2** fail. **Step 3** implement controller + routes (mirror the canonical controller exactly; RequirePermission module/action; RFC7807). For tests, inject the Sender as a configurable module (Application env) so it can be stubbed. **Step 4** pass (focused: `mix test test/settlement_service_web/controllers/admin/reda_controller_test.exs`). **Step 5** commit `feat(reda): admin RedaController + routes`.

---

## Phase E: Frontend (pix/frontend/admin)

### Task 7: API service `reda.ts`

**Files:**
- Read first: `src/services/recurrence.ts` and `src/services/api.ts` (the axios instance + how `api` is imported and how CSRF/ISPB are injected). Mirror the import + error shape exactly.
- Create: `src/services/reda.ts`
- Test: `src/services/__tests__/reda.spec.ts` (mock axios `api`, assert URL/params).

Implement `list/getById/create/update/deregister/getHistory` against `/api/v1/admin/reda/indirect-participants[...]`, returning normalized `{data,total}` like `recurrence.ts`. TDD: spec first (mock api.get/post/put/delete), then implement, then green. Commit `feat(reda): frontend api service`.

### Task 8: `RedaListView.vue` + route + nav + i18n

**Files:**
- Read first: `src/views/RecurrenceListView.vue` (layout, dark CSS vars, pagination), `src/router/index.ts` (children pattern), `src/components/layout/Sidebar.vue` (navSections), `src/locales/pt-BR.json` (structure).
- Create: `src/views/reda/RedaListView.vue`
- Modify: `src/router/index.ts` (add `/reda`, `/reda/new`, `/reda/:id`), `Sidebar.vue` (operational nav item, permission-gated), `src/locales/pt-BR.json` (`nav.reda`, `reda.list.*`, `reda.status.*`).
- Test: `src/views/reda/__tests__/RedaListView.spec.ts`

List columns: ISPB indireto, CNPJ, nome, status (badge via `reda.status.*`), data. Filter by status, pagination. "Novo participante indireto" button -> `/reda/new`. ALL strings via `t(...)`. TDD: spec (renders rows from mocked service, status badge text) first. Commit `feat(reda): RedaListView + route + nav + i18n`.

### Task 9: `RedaDetailView.vue` (create/edit/deregister/history)

**Files:**
- Create: `src/views/reda/RedaDetailView.vue`
- Modify: `pt-BR.json` (`reda.form.*`, `reda.history.*`, validation messages).
- Test: `src/views/reda/__tests__/RedaDetailView.spec.ts`

Create mode: reda.014 fields (ISPB, CNPJ, nome) with client-side validation mirroring backend (8 digits / 14 digits / <=100). Edit mode: reda.022 fields (contact, director, tech_keyword [A-Za-z0-9]{8}, CPF 11) with validation. "Descadastrar" (reda.031) only enabled when status ACTIVE, with confirm dialog. "Historico" section calling `getHistory`. Nothing shows as concluded before reda.016 (status drives the badge). TDD: spec (validation blocks submit on bad CNPJ; deregister disabled unless ACTIVE) first. Commit `feat(reda): RedaDetailView (create/edit/deregister/history)`.

### Task 10: Frontend gate

Run `pnpm type-check` and `pnpm build` and `pnpm test:run` (the reda specs). All green. Commit any fixes `chore(reda): frontend type-check/build/specs green`.

---

## Phase F: Verify + deploy

### Task 11: Backend gate
`cd pix/backend && mix compile --force` (success, pre-existing warnings ok) + run all focused REDA ExUnit files. Record any 28P01-blocked DB tests explicitly. Commit nothing or fixes only.

### Task 12: Deploy to homolog
- Build + push pix-api and pix-admin-ui images (docker buildx arm64 -> ECR), run the DB migration as a one-off ECS task (Task 1), `ecs update-service --force-new-deployment`, wait rollout COMPLETED, confirm by image DIGEST and target group healthy (`AWS_PROFILE=vulcimonetarie`). Record new task-def revisions.

### Task 13: Live E2E (ONLY with explicit owner authorization)
With authorization: register a real indirect participant via the UI -> reda.014 signed at HSM + sent on CSM -> reda.016 received -> row ACTIVE in the UI; then update (022) and deregister (031). Capture screenshots + metadata, validator zero errors (rule #11). Without authorization: stop at XSD-valid + dry-run + UI smoke; mark live proof deferred.

---

## Definition of done
- Migration applied (homolog); `Shared.Reda` + schema + sender + correlation green (focused ExUnit / XSD); RedaController routes return correct codes; UI list+detail operate with full i18n; type-check/build/specs green; pix-api + pix-admin-ui deployed (rollout COMPLETED, confirmed by digest, target groups healthy); live E2E done or explicitly deferred.
- Update memories at session end: `monetarie-pix-ib-core-receipt` or a new `monetarie-reda-outbound` topic + MEMORY.md + `mem:monetarie/work-ahead` (mark REDA outbound done), per the project memory rules.

## Execution handoff
Use superpowers:subagent-driven-development (this session) or superpowers:executing-plans (separate session). Fresh subagent per task + code review between tasks. TDD throughout.
