# Onda 2.D — Senha Management (Full per plan)

> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this plan task-by-task. Pattern: Manual Verification mandatory (this sub-PR introduces 2 new BCB-facing wire-format claims for `senhaws` host endpoints).

**Branch:** `sta/onda2-password-mgmt` (this worktree, cut from `origin/main` HEAD `78b01df7` post Onda 2.C deploy plumbing merge)
**PR target:** `main`
**Scope chosen:** **(A) Full per plan** — `Client.password_change/2` + `password_expiry/0` + `PasswordManager` GenServer + **Oban full setup** + `PasswordExpiryWorker` cron worker. NO trim deferrals.
**Estimated effort:** 1.5-2 days (compressed via PIX PW1.3.2 Oban template re-use). LOC delta estimate: ~500 (libs + migration + tests).
**Parent plan:** `sta/docs/plans/2026-04-26-sta-conformance-rollout.md` §Sub-PR 2.D (lines 774-799).
**Predecessor handoff:** `sta/docs/handoff/2026-04-27-sta-paridade-EOS-COMPREHENSIVE.md`.

---

## Index

1. [Task 0 — Manual Verification](#task-0)
2. [Architecture](#architecture)
3. [Pre-flight](#pre-flight)
4. [Tasks](#tasks)
5. [Acceptance](#acceptance)
6. [Out of scope](#out-of-scope)
7. [References](#references)

---

<a id="task-0"></a>
## Task 0 — Manual Verification

Performed against `sta/Manual_STA_Web_Services.pdf` v1.5 jul/2022 §9 + `/tmp/manual_sta.txt` extract.

### `[VERIFIED §X.Y]` claims

| # | Claim | Manual section / line | Notes |
|---|---|---|---|
| 1 | senhaws is hosted on **separate base URLs** distinct from STA endpoints | §9 intro (line 1097-1099 of extract) | `https://www9.bcb.gov.br/senhaws` (homol) vs `https://www3.bcb.gov.br/senhaws` (prod) |
| 2 | `PUT /senhaws/senha` for password change | §9.1.1 request (line 1116) | |
| 3 | `Content-Type: application/xml` REQUIRED on §9.1 PUT | §9.1.1 Atenção (line 1124) | Same as §7.1 `change_status` |
| 4 | §9.1 body shape: `<Parametros><Senha>...</Senha><NovaSenha>...</NovaSenha><ConfirmacaoNovaSenha>...</ConfirmacaoNovaSenha></Parametros>` | §9.1.1 listing (line 1117-1122) | XML 1.0 UTF-8 standalone="yes" header |
| 5 | §9.1 success: HTTP 204 No Content | §9.1.1 (line 1133) | |
| 6 | §9.1 errors: 400 only with XML body | §9.1.1 errors table (line 1138) | "Houve algum erro. Verificar mensagem dentro do XML de retorno." |
| 7 | `GET /senhaws/senha/vencimento` for password expiry query | §9.2.1 request (line 1156) | |
| 8 | §9.2 success: HTTP 200 with `<Resultado><DiasVencimentoSenha>{n}</DiasVencimentoSenha></Resultado>` | §9.2.1 listing (line 1163-1165) | |
| 9 | §9.2 errors: 400 only with XML body | §9.2.1 errors table (line 1172) | |

### `[STUDY-CONJECTURE]` items (Manual silent — conservative reads applied)

| # | Topic | Manual silence | Decision |
|---|---|---|---|
| 1 | Whether `Content-Type` should be in the §9.2 GET request | Manual silent — only §9.1 PUT explicitly requires `application/xml` | Apply same convention as other GETs (§6.1, §6.4, §8.1, §8.2): omit Content-Type. Conservative; if BCB rejects we'll see it in smoke. |
| 2 | What "expiring soon" threshold should trigger an alert | Manual reports days remaining but doesn't recommend a threshold | Apply 2-tier: ≤7 days → `Notifications.notify(:warn, ...)`; ≤2 days → `Notifications.notify(:error, ...)`. Operator gets escalation room. Configurable via env var if operators want different. |
| 3 | What auth `Client.password_change/2` uses (current pass to authenticate, then change) | Manual §9.1 doesn't explicitly state | Use HTTP Basic with current `STA_BCB_USERNAME` / `STA_BCB_PASSWORD` (same as STA endpoints). The body's `<Senha>` field is ALSO the current pass — possibly redundant, but Manual is explicit about the body shape. Send both. |
| 4 | What happens if `password_change` returns success but the new pass is wrong on next call | Manual silent | Reactive: PasswordManager schema does NOT auto-rotate the env-var Secrets Manager value. Operator MUST update `monetarie/{env}/sta/bcb_password` manually after a successful change. Documented in handoff §Operator runbook. |
| 5 | Whether `:password_expiry` should be in the `:queries` rate-limit bucket | Manual §2.6 §9 silent on whether senhaws hits count toward STA rate limits | Conservative: routed through `:queries` bucket (same as other GETs) since senhaws is "Consulta-like". If BCB has separate limits, operators learn from observed behavior. |

### `[NEEDS-OPERATOR-CONFIRM]` items

| # | Topic | Why operator confirmation needed |
|---|---|---|
| 1 | Cron schedule UTC offset | Plan §Sub-PR 2.D suggests 06:00 BRT daily = `0 9 * * *` UTC. Confirm BRT≡UTC-3 (no DST in Brazil since 2019 — should be safe but check) |
| 2 | Notify severity escalation thresholds (≤7 warn / ≤2 error) | [STUDY-CONJECTURE] #2 chose 7/2 but operator may want different. Configurable via env var `STA_PWD_EXPIRY_WARN_DAYS` + `STA_PWD_EXPIRY_ERROR_DAYS` |

### Misdiagnoses anticipated

None for Onda 2.D — Manual §9 is brief (24 pages of original doc) and unambiguous. The only catch is the **separate base URL** which is plain text in §9 intro.

---

<a id="architecture"></a>
## Architecture

### Component 1: `Client.password_change/2` + `Client.password_expiry/0`

**Path modifications:**
- `lib/sta_connector/sta/client.ex` — new build_rest_request clauses + parse_rest_response clauses; new private helper `get_password_endpoint_url/1` that mirrors `get_endpoint_url/1` but routes `:password_change`/`:password_expiry` to `www9.bcb.gov.br/senhaws` (homol) / `www3.bcb.gov.br/senhaws` (prod).
- `lib/sta_connector/sta/client_behaviour.ex` — new callbacks.

**Public API:**

```elixir
@callback password_change(
            %{
              required(:current_password) => String.t(),
              required(:new_password) => String.t()
            },
            keyword()
          ) :: {:ok, :ok} | {:error, Error.t()}

@callback password_expiry(keyword()) ::
            {:ok, %{days_remaining: non_neg_integer()}} | {:error, Error.t()}
```

**Wire format (per [VERIFIED §9.1.1]):**

```
PUT https://www9.bcb.gov.br/senhaws/senha
Content-Type: application/xml
Authorization: Basic <STA_BCB_USERNAME:STA_BCB_PASSWORD>

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Parametros>
  <Senha>{current_password}</Senha>
  <NovaSenha>{new_password}</NovaSenha>
  <ConfirmacaoNovaSenha>{new_password}</ConfirmacaoNovaSenha>
</Parametros>
```

**Wire format (per [VERIFIED §9.2.1]):**

```
GET https://www9.bcb.gov.br/senhaws/senha/vencimento
Authorization: Basic <STA_BCB_USERNAME:STA_BCB_PASSWORD>
(no Content-Type per [STUDY-CONJECTURE] #1)
```

**Endpoint routing decision:** introduce a 2-arg `get_endpoint_url/2` helper:

```elixir
defp get_endpoint_url(config, :sta), do: <existing logic>
defp get_endpoint_url(config, :senha) do
  case Map.get(config, :environment, :homologation) do
    :production -> "https://www3.bcb.gov.br/senhaws"
    _ -> "https://www9.bcb.gov.br/senhaws"
  end
end
```

The `execute_request/3` dispatches `:password_change`/`:password_expiry` ops to the `:senha` endpoint variant; everything else stays `:sta`.

### Component 2: `PasswordManager` GenServer

**Path:** `lib/sta_connector/sta/password_manager.ex`

**Public API:**

```elixir
@spec check_expiry(opts :: keyword()) ::
        {:ok, %{days_remaining: non_neg_integer(), notified: :ok | :warn | :error | :none}}
        | {:error, Error.t()}
```

**Behavior:**
1. Calls `Client.password_expiry/0`.
2. Pure decision function `notify_decision/1`:
   - `days_remaining > 7` → `:none` (no alert)
   - `2 < days_remaining <= 7` → `:warn` (`Notifications.notify(:warn, ...)`)
   - `days_remaining <= 2` → `:error` (`Notifications.notify(:error, ...)`)
3. Returns map with `days_remaining` + decision atom.

**Configuration:**
- `STA_PWD_EXPIRY_WARN_DAYS` env var (default 7) — threshold para `:warn`
- `STA_PWD_EXPIRY_ERROR_DAYS` env var (default 2) — threshold para `:error`
- Both validate at boot: `error_days <= warn_days` ou raise.

**GenServer rationale:** PasswordManager é GenServer mesmo sendo stateless porque (a) facilita Mox em tests integration, (b) dá um pid known em supervisor pra Worker chamar, (c) encapsula access ao Notifications module com retry idempotente.

### Component 3: Oban full setup

**Files to modify:**
- `mix.exs` — add `{:oban, "~> 2.18"}`
- `config/config.exs` — Oban config block
- `lib/sta_connector/application.ex` — Oban supervision entry

**File to create:**
- `priv/repo/migrations/20260427XXXXXX_create_oban_jobs.exs` — `Oban.Migrations.up(version: 13)`

**Config block** (mirroring PIX PW1.3.2 with STA-specific queues):

```elixir
config :sta_connector, Oban,
  repo: StaConnector.Repo,
  queues: [default: 10, audit: 5, regulatory: 5],
  plugins: [
    {Oban.Plugins.Pruner, max_age: 60 * 60 * 24 * 7},  # 7-day retention
    {Oban.Plugins.Cron,
     crontab: [
       # 0 9 * * * UTC = 06:00 BRT (Brasília no DST since 2019).
       # PasswordExpiryWorker checks senha vencimento daily; alerts via
       # Notifications.notify if days_remaining ≤ 7 (warn) or ≤ 2 (error).
       {"0 9 * * *", StaConnector.Workers.PasswordExpiryWorker}
     ]},
    Oban.Plugins.Lifeline
  ]
```

**Supervision tree wire** (in Application.start):

```elixir
{Oban, Application.fetch_env!(:sta_connector, Oban)}
```

Placed AFTER Repo + BEFORE workers in the children list so cron worker is supervised after DB available.

### Component 4: `PasswordExpiryWorker` Oban.Worker

**Path:** `lib/sta_connector/workers/password_expiry_worker.ex`

**Public API:**

```elixir
defmodule StaConnector.Workers.PasswordExpiryWorker do
  use Oban.Worker, queue: :audit, max_attempts: 5

  @impl Oban.Worker
  def perform(%Oban.Job{args: _args}) do
    case StaConnector.Sta.PasswordManager.check_expiry() do
      {:ok, _result} -> :ok
      {:error, %{retryable: false}} -> {:cancel, "non-retryable BCB error"}
      {:error, _} -> {:error, "retryable transient failure"}
    end
  end
end
```

**Why `:audit` queue:** non-money path, low priority. Mirrors PIX PW1.3.2 audit-queue pattern. 5 retries with exponential backoff (Oban default).

**Cron via `Oban.Plugins.Cron`:** triggered automatically; no admin endpoint needed for production. For ad-hoc testing, operator runs `Oban.insert(StaConnector.Workers.PasswordExpiryWorker.new(%{}))`.

### Files to create

| File | Purpose | Est. LOC |
|---|---|---|
| `lib/sta_connector/sta/password_manager.ex` | GenServer + decision logic | ~120 |
| `lib/sta_connector/workers/password_expiry_worker.ex` | Oban.Worker shell | ~30 |
| `priv/repo/migrations/20260427XXXXXX_create_oban_jobs.exs` | Oban schema | ~10 |
| `test/sta_connector/sta/password_manager_test.exs` | Pure decision logic tests | ~100 |

### Files to modify

| File | Modification | Est. LOC delta |
|---|---|---|
| `lib/sta_connector/sta/client.ex` | New build/parse clauses + `get_endpoint_url/2` 2-arg | +60 |
| `lib/sta_connector/sta/client_behaviour.ex` | 2 new callbacks | +30 |
| `lib/sta_connector/application.ex` | Oban supervision child | +5 |
| `mix.exs` | `{:oban, "~> 2.18"}` | +1 |
| `config/config.exs` | Oban config block | +20 |
| `test/sta_connector/sta/client_test.exs` | New describe blocks: password_change (4 tests) + password_expiry (3 tests) | +130 |

**Total estimated:** ~500 LOC delta (~50% tests + Oban migration ceremony).

---

<a id="pre-flight"></a>
## Pre-flight

**Already verified at session start:**
- ✅ Branch `sta/onda2-password-mgmt` cut off `origin/main` `78b01df7`
- ✅ `Finch` pools for `www9.bcb.gov.br` + `www3.bcb.gov.br` already wired in Onda 1 with mTLS via ICP-Brasil chain
- ✅ `Notifications.notify/4` already exists (`lib/sta_connector/notifications.ex:100`) — no need for new helper
- ✅ Manual §9 inventory pinned (Task 0)
- ❌ Oban NOT in mix.exs — needs full setup (Task 3)
- ❌ `:audit` queue does NOT exist — Task 3 creates it via Oban config

---

<a id="tasks"></a>
## Tasks

### Task 1 — `Client.password_change/2` + `Client.password_expiry/0`

**TDD red:** Add to `client_test.exs`:

```elixir
describe "password_change/2 wire format (Manual §9.1)" do
  test "PUT /senhaws/senha with Content-Type=application/xml + canonical XML body"
  test "204 → {:ok, :ok}"
  test "400 → {:error, %Error{}}"
  test "routes to www9.bcb.gov.br in homol environment"
end

describe "password_expiry/0 wire format (Manual §9.2)" do
  test "GET /senhaws/senha/vencimento without Content-Type"
  test "200 with <DiasVencimentoSenha>30</DiasVencimentoSenha> → {:ok, %{days_remaining: 30}}"
  test "400 → {:error, %Error{}}"
end
```

**TDD green:**
1. Modify `lib/sta_connector/sta/client.ex`:
   - Refactor `get_endpoint_url/1` → `get_endpoint_url/2` with `kind` parameter (`:sta` default vs `:senha`).
   - New `defp build_rest_request(:password_change, %{current_password: ..., new_password: ...})` returning `{:put, "/senha", xml, [{"Content-Type", "application/xml"}]}`.
   - New `defp build_rest_request(:password_expiry, _)` returning `{:get, "/senha/vencimento", nil, []}`.
   - New `defp parse_rest_response(:password_change, 204, _, _) do {:ok, :ok} end`.
   - New `defp parse_rest_response(:password_expiry, 200, body, _)` parsing `<DiasVencimentoSenha>` via SweetXml.
   - In `execute_request/3`: dispatch ops `:password_change` / `:password_expiry` to `get_endpoint_url(config, :senha)` (else `:sta`).
2. Modify `client_behaviour.ex`: 2 new `@callback`s.
3. Public functions `Client.password_change/2` + `Client.password_expiry/0` (no map arg for expiry — argument-less).

**Commit message:** `feat(sta): Client.password_change/2 + Client.password_expiry/0 per Manual §9 (Onda 2.D Task 1)`

**Estimate:** 2-3h.

---

### Task 2 — `PasswordManager` GenServer + tests

**TDD red:** Create `test/sta_connector/sta/password_manager_test.exs`:

```elixir
describe "notify_decision/1 (pure)" do
  test "days > 7 → :none"
  test "days == 7 → :warn"
  test "days == 3 → :warn"
  test "days == 2 → :error"
  test "days == 0 → :error"
  test "days < 0 → :error (already expired)"
end

describe "check_expiry/1 with stubbed Client" do
  test "Client returns 30 → {:ok, %{days_remaining: 30, notified: :none}}, no notification"
  test "Client returns 5 → notified: :warn, Notifications received warn-severity payload"
  test "Client returns 1 → notified: :error"
  test "Client error → {:error, ...}, no notification"
end
```

**TDD green:**
1. Create `lib/sta_connector/sta/password_manager.ex`:
   - `use GenServer`
   - State holds `{warn_threshold, error_threshold, client_module, notifications_module}` from env at init.
   - Public `check_expiry/1` is GenServer.call delegating to `do_check/1` private.
   - Pure `notify_decision/3` taking `(days, warn_t, error_t)` returning `:error | :warn | :none`.
   - Private `do_check/1` orchestrates: call client → notify_decision → notify if needed → return result.
2. Add `Application.fetch_env!` reads for `STA_PWD_EXPIRY_WARN_DAYS` (default 7) + `STA_PWD_EXPIRY_ERROR_DAYS` (default 2). Validate `error <= warn`.
3. Add to supervision tree (after Repo, before Oban entry).

**Commit message:** `feat(sta): PasswordManager GenServer + decision logic (Onda 2.D Task 2)`

**Estimate:** 2-3h.

---

### Task 3 — Oban full setup

**TDD red:** N/A — infra task. Touchstones validate post-step.

**TDD green:**
1. `mix.exs` — add `{:oban, "~> 2.18"}` to deps.
2. Run `mix deps.get` — verify clean download.
3. Create `priv/repo/migrations/20260427HHMMSS_create_oban_jobs.exs`:
   ```elixir
   defmodule StaConnector.Repo.Migrations.CreateObanJobs do
     use Ecto.Migration
     def up,   do: Oban.Migrations.up(version: 13)
     def down, do: Oban.Migrations.down(version: 13)
   end
   ```
4. `config/config.exs` — Oban config block (queues + plugins). Cron crontab will be empty in this task (Worker added in Task 4).
5. `lib/sta_connector/application.ex` — add `{Oban, Application.fetch_env!(:sta_connector, Oban)}` after Repo, with `Process.whereis(Oban)` defensive check (mirror PIX PW1.3.2 pattern).

**Touchstone post-step:** `mix compile` must produce 5 warnings; `mix test test/sta_connector/application_test.exs` must pass (3/3).

**Commit message:** `feat(sta): Oban full setup — deps + migration + supervision (Onda 2.D Task 3)`

**Estimate:** 2-3h.

---

### Task 4 — `PasswordExpiryWorker` + cron

**TDD red:** Create `test/sta_connector/workers/password_expiry_worker_test.exs`:

```elixir
describe "perform/1" do
  test "PasswordManager returns {:ok, _} → :ok (success)"
  test "PasswordManager returns {:error, retryable} → {:error, _}"
  test "PasswordManager returns {:error, non-retryable} → {:cancel, _}"
end
```

**TDD green:**
1. Create `lib/sta_connector/workers/password_expiry_worker.ex`:
   ```elixir
   use Oban.Worker, queue: :audit, max_attempts: 5
   ```
2. `perform/1` calls `PasswordManager.check_expiry()` and routes per Architecture §Component 4.
3. Add cron entry to `config/config.exs` Oban plugins list:
   ```elixir
   {Oban.Plugins.Cron,
    crontab: [
      {"0 9 * * *", StaConnector.Workers.PasswordExpiryWorker}
    ]}
   ```

**Commit message:** `feat(sta): PasswordExpiryWorker + daily cron (Onda 2.D Task 4)`

**Estimate:** 1-2h.

---

### Task 5 — Touchstones + handoff

**Touchstones run (combined batch with 2.D additions):**

```bash
mix test \
  test/sta_connector/sta/client_test.exs \
  test/sta_connector/sta/operations_test.exs \
  test/sta_connector/files/outbound_file_test.exs \
  test/sta_connector/application_test.exs \
  test/sta_connector/outbound_test.exs \
  test/sta_connector_web/controllers/api/admin/outbound_files_controller_test.exs \
  test/sta_connector/sta/state_code_test.exs \
  test/sta_connector/inbound/worker_test.exs \
  test/sta_connector/outbound/worker_test.exs \
  test/sta_connector_web/controllers/api/admin/sta_query_controller_test.exs \
  test/sta_connector/sta/rate_limiter_three_bucket_test.exs \
  test/sta_connector/sta/password_manager_test.exs \
  test/sta_connector/workers/password_expiry_worker_test.exs
```

Expected post-2.D: ~285 tests, 6 baseline failures preserved (~23 new from 2.D).

Compile: 5 baseline warnings preserved. Migration `20260427XXXXXX_create_oban_jobs.exs` runs cleanly via `mix ecto.migrate` against test DB.

**Handoff:** `sta/docs/handoff/2026-04-27-sta-onda2d-full-complete.md`.

**Commit message** (handoff): `docs(sta): Onda 2.D (full) end-of-session handoff`

**Estimate:** 1h.

---

<a id="acceptance"></a>
## Acceptance

From rollout plan §Sub-PR 2.D (verbatim):

- ✅ "Senha can be changed via API call" — Task 1 covers `Client.password_change/2`.
- ✅ "Cron emits a notification when expiry ≤ 7 days" — Task 2 + Task 4 (PasswordManager + Worker via Cron plugin).
- ✅ "No surprise downtime from senha expiring silently" — daily cron alert at ≤7 / ≤2 days.

Plus (A) full extras:
- Oban full setup (deps + migration + supervision + config) lands as part of this PR.
- Manual Verification gate satisfied (9 [VERIFIED §X.Y] + 5 [STUDY-CONJECTURE] + 2 [NEEDS-OPERATOR-CONFIRM]).
- No out-of-scope changes (`git diff base..HEAD --name-only | grep -v "^sta/"` empty).

---

<a id="out-of-scope"></a>
## Out of scope

Carried forward + new items:

1. **Auto-rotation of `monetarie/{env}/sta/bcb_password` Secrets Manager value** after a successful `password_change`. Operator must update the secret manually post-change. Documented in handoff §Operator runbook. ([STUDY-CONJECTURE] #4)
2. **Admin endpoints** for ad-hoc trigger (`POST /api/admin/sta/password/change`, `GET /api/admin/sta/password/expiry`). Cron handles the scheduled case; ad-hoc operator can run via `bin/sta_connector eval`.
3. **2.C-2 follow-up `protocol_expires_at` field** — still deferred per Onda 2.C handoff.
4. **G5 `:admin_api` RBAC** — separate hygiene PR.
5. **Onda 3 leiautes corpus + XSD validator** — independent.
6. **Onda 4 identifier mapping in `core/`** — independent + cross-cabin.
7. **Real BCB smoke test** — operator post-merge.
8. **Telemetry metrics** for password-mgmt — Wave 11 P2 framework.
9. **Multi-environment sanity** — code is env-aware (homologation vs production base URL routing) but smoke test only happens in homologation per Monetarie policy.
10. **Validate `error_days <= warn_days` at runtime** — done at boot via `Application.fetch_env!` validation; if env vars are misconfigured, supervisor refuses to start (fail-CLOSED).

---

<a id="references"></a>
## References

### Plan docs
- **Parent rollout plan:** `sta/docs/plans/2026-04-26-sta-conformance-rollout.md` §Sub-PR 2.D (lines 774-799).
- **Onda 2.B/2.C plans (templates):** same dir.
- **PIX PW1.3.2 Oban template** (cross-cabin reference): `pix/backend/apps/shared/priv/repo/migrations/20260426204500_create_oban_jobs.exs` + `pix/backend/config/config.exs` Oban block.

### Handoff docs
- **Predecessor:** `sta/docs/handoff/2026-04-27-sta-paridade-EOS-COMPREHENSIVE.md`.

### Manual STA references
- §9 intro (line 1092 of extract).
- §9.1 Alteração de senha (line 1105) + §9.1.1 Especificação (line 1108).
- §9.2 Consulta vencimento (line 1148) + §9.2.1 Especificação (line 1152).

### Onda 1 + 2.A + 2.B + 2.C merged PRs (cross-reference)
| PR | SHA | Title |
|---|---|---|
| #24 | `301503ec` | Onda 1 — wire-format + ICP-Brasil chain (also wired Finch pools for senhaws hosts) |
| #62 | `67924840` | Onda 2.B status & queries (C full+admin) |
| #66 | `a55ad325` | Onda 2.C rate & lifecycle (B trim) |
| #74 | `78b01df7` | Onda 2.C deploy plumbing — CI workflow |

---

**End of plan.** Implementation begins at Task 1.
