# Onda 2.C — Rate & Lifecycle (B trim)

> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this plan task-by-task. Pattern: Manual Verification mandatory (this sub-PR introduces new wire-format claims about HTTP 410 semantics + 429 Retry-After + per-bucket rate limits).

**Branch:** `sta/onda2-rate-and-lifecycle` (this worktree, cut from `origin/main` HEAD `67924840` post Onda 2.B merge)
**PR target:** `main`
**Scope chosen:** **(B) trim** — 3 rate buckets (façade) + 410 per-operation mapping + 429 `Retry-After` parsing + Outbound.Worker auto-renew + delayed retry. **DEFERS** the proactive `protocol_expires_at` migration / schema field to a 2.C-2 follow-up when an admin-UI consumer actually exists. Same lesson learned as 2.B (B): don't ship orphan fields without callers. Reactive 410 handling already covers 100% of BCB-side correctness.
**Estimated effort:** 1.5 days actual · LOC delta estimate: ~400 (libs + tests).
**Parent plan:** `sta/docs/plans/2026-04-26-sta-conformance-rollout.md` §Sub-PR 2.C (lines 739-770).
**Predecessor handoff:** `sta/docs/handoff/2026-04-27-sta-onda2b-full-admin-complete.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

Per the **Pattern: Manual Verification** mandated for every Ondas 2-4 sub-PR. Performed against `sta/Manual_STA_Web_Services.pdf` v1.5 jul/2022 + `/tmp/manual_sta.txt` extract (regenerable via `pdftotext -layout`).

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

| # | Claim | Manual section | Notes |
|---|---|---|---|
| 1 | 48h protocol expiry | §5 intro (line 267 of extract) | "Caso o envio do arquivo não seja finalizado em até 48 horas, o protocolo será cancelado." |
| 2 | HTTP 410 in §5.6 errors table = "O protocolo informado foi cancelado pelo Banco Central do Brasil" | §5.6 (line 564) | Distinct from §6.1/§6.4 410 semantics. |
| 3 | HTTP 410 in §6.1 errors table = "O arquivo não está disponível para download" | §6.1 (line 653) | The `:download_file` and `:download_file_streamed` mapping today. |
| 4 | HTTP 410 in §6.4 errors table = "O arquivo não está disponível para download" | §6.4 (line 738) | The `:download_part` mapping today. |
| 5 | "Máximo de 10 simultâneos" envio/recebimento de arquivo | §2.6 (line 190) | Concurrent transfers cap; applies to upload + download paths jointly. |
| 6 | "Máximo de 120 por minuto" Consulta | §2.6 (line 192) | Per-minute query rate; applies to `list_available_files`/`query_protocol`/`advanced_query`/`posicao_upload`. |

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

| # | Topic | Manual silence | Decision |
|---|---|---|---|
| 1 | HTTP 429 `Retry-After` header on STA endpoints | Manual does not document 429 anywhere — the 429 status is RFC 6585 / RFC 7231 standard, surfaced when STA enforces server-side rate limits beyond the Manual's documented client-side caps | Implement per RFC 7231 §7.1.3: parse `Retry-After` as `delta-seconds` (preferred) OR HTTP-date (fallback). On parse failure or missing header, default to 60 seconds. Log warning on HTTP-date form (rare, suggests BCB extended their server). |
| 2 | Status-change endpoint (§7.1) per-minute rate cap | §2.6 cap is for "Consulta" only — change_status (§7.1) doesn't fit either §2.6 bucket textually | Plan §Sub-PR 2.C calls 10/min. This is a conservative invention NOT in the Manual. Justification: status changes are mutating ops; same rate envelope as transfers (10 simultaneous = ≤10/sec ≈ 600/min, but bursty). 10/min is a low-cost conservative cap that protects BCB from a rogue worker loop. Document as conservative read; expose env var to relax. |
| 3 | Whether the §2.6 "10 simultâneos" cap applies to `:request_protocol` | Manual says "Envio e recebimento de arquivo" — request_protocol is the prelude to envio | Treat `:request_protocol` as part of `:transfers` bucket. If Manual writers intended otherwise, our cap is conservative (over-restricts), not under-restricts. |
| 4 | Whether the inner-Client check (line 757 `case check_rate_limit(state)`) should be removed when 3-bucket façade is added | Plan implies "replace single-bucket impl" but the Client-internal check is a separate codepath | Keep the Client-internal check as a cheap pre-flight (already there, working, has 5 baseline warnings unchanged). 3-bucket façade is opt-in via `:rate_limiter` option in Operations. Smaller blast radius. Removing the inner check is a separate hygiene PR. |
| 5 | `:protocol_expired` fan-out across upload paths | §5.6 explicitly documents 410 in its errors table; §5.1 (Requisição), §5.2 (Envio completo), §5.3 (Consulta a situação) errors tables NOT inspected | Apply `:protocol_expired` to all 4 upload-side operations: `:upload_file`, `:upload_part`, `:posicao_upload`, `:request_protocol`. Conservative: BCB might return 410 on any of them once a protocol is cancelled. |

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

| # | Topic | Why operator confirmation needed |
|---|---|---|
| 1 | `:status_changes` 10/min cap (see [STUDY-CONJECTURE] #2) | Plan invented this cap. If operators have observed BCB rejecting batched §7 calls, share that data so we can pin the cap. Otherwise stay conservative. |
| 2 | Worker protocol-renewal cap = 1 attempt | After one renewal, if the new protocol also returns 410, fail the upload. Could be 0 (fail-fast on first 410) or 2+ (chase BCB harder). 1 is the YAGNI middle ground. |

### Misdiagnoses anticipated

The pre-flight surfaced one important semantic distinction: **HTTP 410 carries DIFFERENT meanings on upload vs. download paths.** This is documented in the Manual itself but not in the existing client code's docstrings. The fix in Task 2 makes the per-operation mapping explicit + cites the Manual sections inline.

---

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

### Component 1: 3-bucket RateLimiter façade

**Path modifications:**
- `lib/sta_connector/sta/rate_limiter.ex` — refactor to façade (or wrap-and-dispatch). Keep public API (`acquire/2`, `acquire/3`, `release/2`, `status/1`, `reset/1`) for backward compat — Operations callers don't change.
- New: `lib/sta_connector/sta/rate_limiter/transfers.ex` — 10 concurrent slots (§2.6 line 190).
- New: `lib/sta_connector/sta/rate_limiter/queries.ex` — 120 tokens/minute refill (§2.6 line 192).
- New: `lib/sta_connector/sta/rate_limiter/status_changes.ex` — 10 tokens/minute (conservative; [STUDY-CONJECTURE] #2).

**Internal design — choice between two impls:**
- (a) **One GenServer + 3 internal buckets** — single `RateLimiter` GenServer holds all 3 buckets in its State; `acquire/2` takes `bucket :: :transfers | :queries | :status_changes`. Simpler supervision tree, less ceremony.
- (b) **Three separate GenServers** — `RateLimiter.Transfers`, `RateLimiter.Queries`, `RateLimiter.StatusChanges` all started in supervision tree. Plan literally suggests this with separate file modules.

**Decision: (a) one GenServer with 3 buckets.** Plan-as-written hints at separate processes via separate file modules, but a single GenServer with internal bucket dispatch:
- Reuses the existing token-bucket logic, queue-on-wait, telemetry — no copy-paste.
- One supervision tree entry (already wired).
- Per-bucket modules become thin namespaces with constants + behaviour (e.g., `RateLimiter.Transfers.max_concurrent/0`, `RateLimiter.Queries.max_per_minute/0`, `RateLimiter.StatusChanges.max_per_minute/0`).

**API change (additive, backward-compatible):**

```elixir
# Before:
RateLimiter.acquire(name, :query)        # query token bucket
RateLimiter.acquire(name, :concurrent)   # concurrent slot
RateLimiter.acquire_both(name)            # both at once

# After:
RateLimiter.acquire(name, :transfers)        # was :concurrent
RateLimiter.acquire(name, :queries)          # was :query
RateLimiter.acquire(name, :status_changes)   # NEW
RateLimiter.acquire_both(name)               # kept (transfers + queries — for backward compat; matches old behavior)

# Backward-compat aliases (deprecated, log warning):
RateLimiter.acquire(name, :query)        # delegates to :queries + Logger.warning
RateLimiter.acquire(name, :concurrent)   # delegates to :transfers + Logger.warning
```

**Why keep aliases**: `Operations.maybe_acquire_rate_limit/1` calls `acquire/2` with the old atoms. Rather than touch Operations in this PR, alias the old atoms with deprecation warnings so existing call sites keep working. Operations refactor is a separate hygiene PR.

**Telemetry events**:

```elixir
:telemetry.execute([:sta, :rate_limiter, :acquire], %{wait_ms: 0}, %{bucket: :transfers, status: :ok})
:telemetry.execute([:sta, :rate_limiter, :acquire], %{wait_ms: 1234}, %{bucket: :queries, status: :wait})
:telemetry.execute([:sta, :rate_limiter, :acquire], %{wait_ms: 30_000}, %{bucket: :transfers, status: :timeout})
```

Existing Wave 11 P2 metrics framework picks these up at telemetry-handler attach time. No metric definitions in this PR (deferred per parent rollout plan).

### Component 2: HTTP 410 per-operation mapping

**Path:** `lib/sta_connector/sta/client.ex` — extend the existing `map_operation_error/2` clauses.

**Mapping table (post-Onda 2.C):**

| Operation | 410 maps to | Manual citation | Behavior |
|---|---|---|---|
| `:download_part` | `:file_unavailable` | §6.4 (line 738) | UNCHANGED (existing). |
| `:download_file_streamed` | `:file_unavailable` | §6.1 (line 653) | UNCHANGED (existing). |
| `:download_file` | `:file_unavailable` | §6.1 | NEW (today maps via `Error.from_http_status`; explicit clause makes intent clear). |
| `:upload_file` | `:protocol_expired` | §5.2 (inferred from §5.6) | **NEW.** |
| `:upload_part` | `:protocol_expired` | §5.6 (line 564) | **NEW.** |
| `:posicao_upload` | `:protocol_expired` | §5.3 (inferred from §5.6) | **NEW.** |
| `:request_protocol` | `:protocol_expired` | §5.1 (inferred from §5.6) | **NEW.** Treat as "you tried to renew on a protocol that doesn't exist". |
| `:list_available_files` | (default `Error.from_http_status`) | — | NO CHANGE. 410 not documented for §8.1. |
| `:query_protocol` | (default `Error.from_http_status`) | — | NO CHANGE. 410 not documented for §8.2. |
| `:advanced_query` | (default `Error.from_http_status`) | — | NO CHANGE. |
| `:change_status` | (default `Error.from_http_status`) | — | NO CHANGE. §7.1 errors table only documents 400. |

**Behaviour callback updates:** `client_behaviour.ex` adds `:protocol_expired` to the error union for `:upload_file`, `:upload_part`, `:posicao_upload`, and `:request_protocol`.

### Component 3: HTTP 429 Retry-After parsing

**Path:** `lib/sta_connector/sta/client.ex` — extend `execute_request/3` to handle status 429 BEFORE the generic `Error.from_http_status` fallback.

**Mapping:**

```elixir
{:ok, %Finch.Response{status: 429, headers: headers, body: body}} ->
  retry_after_seconds = parse_retry_after(headers)
  Logger.warning("[STA Client] #{operation} rate-limited; retry after #{retry_after_seconds}s")
  {:error, {:rate_limited, retry_after_seconds}}
```

**Helper `parse_retry_after/1`:**

```elixir
defp parse_retry_after(headers) do
  case find_header(headers, "retry-after") do
    nil ->
      60  # conservative default per [STUDY-CONJECTURE] #1

    value when is_binary(value) ->
      case Integer.parse(value) do
        {seconds, ""} when seconds >= 0 ->
          seconds

        _ ->
          # HTTP-date form (RFC 7231 §7.1.3 alternate). Could parse it but
          # BCB STA practice is unknown — log warning and default.
          Logger.warning("[STA Client] Retry-After header is non-integer (#{inspect(value)}); defaulting to 60s")
          60
      end
  end
end
```

**Note on per-operation mapping:** unlike HTTP 410, HTTP 429 is a server-side rate enforcement — semantically uniform across all operations. Single mapping clause, no per-operation specialization needed.

### Component 4: Outbound.Worker auto-renew + delayed retry

**Path:** `lib/sta_connector/outbound/worker.ex` — extend the error-dispatch path so two specific atoms get different handling vs. the generic `handle_upload_error/2`.

**State struct change:**

```elixir
defstruct [
  :file,
  :uploader_pid,
  :sta_client,
  max_retries: 3,
  retry_delay_ms: 1_000,
  callback_timeout_ms: 30_000,
  protocol_renewed: false       # NEW — tracks whether we've already auto-renewed once
]
```

**New error-dispatch flow** — extract a pure helper `dispatch_upload_error/2` that takes the error atom + current state and returns one of:
- `{:fail, message}` — non-retryable; mark file failed.
- `{:retry, delay_ms}` — schedule via existing `Process.send_after(self(), {:retry_upload, attempts + 1}, delay_ms)`.
- `{:renew_protocol, message}` — clear `protocol_number`, reset `bytes_uploaded=0`, set `protocol_renewed=true`, restart from `:start_upload`.

```elixir
@spec dispatch_upload_error(error :: term(), state :: %State{}) ::
        {:fail, String.t()}
        | {:retry, non_neg_integer()}
        | {:renew_protocol, String.t()}
def dispatch_upload_error({:rate_limited, n}, _state) when is_integer(n) and n >= 0,
  do: {:retry, n * 1000}

def dispatch_upload_error(:protocol_expired, %State{protocol_renewed: false}),
  do: {:renew_protocol, "STA returned 410 (protocol expired); auto-renewing"}

def dispatch_upload_error(:protocol_expired, %State{protocol_renewed: true}),
  do: {:fail, "STA returned 410 after protocol renewal — giving up to avoid loop"}

def dispatch_upload_error(error, _state),
  do: {:fail, format_error(error)}
```

**Wire-up:** `handle_upload_error/2` calls `dispatch_upload_error/2` and routes:

```elixir
defp handle_upload_error(state, error) do
  file = state.file

  case dispatch_upload_error(error, state) do
    {:retry, delay_ms} ->
      Logger.info("[Worker] Rate-limited; retrying in #{delay_ms}ms")
      Process.send_after(self(), {:retry_upload, file.upload_attempts + 1}, delay_ms)
      {:noreply, state}

    {:renew_protocol, message} ->
      Logger.info("[Worker] #{message} (file #{file.id})")
      {:ok, _} = Outbound.clear_protocol(file.id)
      send(self(), :start_upload)
      {:noreply, %{state | protocol_renewed: true}}

    {:fail, message} ->
      old_handle_upload_error_path(state, message)   # existing logic moved into helper
  end
end
```

**Backward compat:** existing call sites passing string error messages continue to work — `dispatch_upload_error/2` falls through the catch-all clause to `{:fail, format_error(error)}`.

**`Outbound.clear_protocol/1`** — new public function that:
- Sets `protocol_number = nil`
- Sets `bytes_uploaded = 0`
- Sets `status = "pending"` (so `do_upload` flows through `mark_uploading` again cleanly)

**Inbound.Worker NOT touched** — `:protocol_expired` is upload-side only. `:rate_limited` could theoretically apply to inbound queries but the existing inbound worker has its own retry budget via `RetryEntry`; layering 429 sleep on top is a separate concern. Document in plan §Out of scope as deferred.

### Files to create

| File | Purpose | Est. LOC |
|---|---|---|
| `lib/sta_connector/sta/rate_limiter/transfers.ex` | Bucket constants + helpers | ~30 |
| `lib/sta_connector/sta/rate_limiter/queries.ex` | Bucket constants + helpers | ~30 |
| `lib/sta_connector/sta/rate_limiter/status_changes.ex` | Bucket constants + helpers | ~30 |
| `test/sta_connector/sta/rate_limiter_three_bucket_test.exs` | Bucket isolation + concurrency property test | ~150 |

### Files to modify

| File | Modification | Est. LOC delta |
|---|---|---|
| `lib/sta_connector/sta/rate_limiter.ex` | Add 3-bucket internal state; new acquire/2 dispatch; deprecation aliases for `:query`/`:concurrent` | +120/-30 |
| `lib/sta_connector/sta/client.ex` | New `map_operation_error/2` clauses for 4 upload paths; new 429 status handler + `parse_retry_after/1` helper | +50 |
| `lib/sta_connector/sta/client_behaviour.ex` | Add `:protocol_expired` to upload error unions | +10 |
| `lib/sta_connector/outbound/worker.ex` | Add `protocol_renewed` to State; new `dispatch_upload_error/2`; refactor `handle_upload_error/2` | +60 |
| `lib/sta_connector/outbound.ex` | New `clear_protocol/1` | +20 |
| `test/sta_connector/sta/client_test.exs` | New describe blocks: 410 per-op (4 tests) + 429 Retry-After (3 tests) | +120 |
| `test/sta_connector/outbound/worker_test.exs` | Extend with `dispatch_upload_error/2` describe (5 tests) | +80 |

**Total estimated:** ~700 LOC delta (~50% tests). Within the (B) trim target of ~400 LOC implementation; the test surface inflates the total.

---

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

**Already verified at session start (post Onda 2.B merge):**
- ✅ Worktree on `sta/onda2-rate-and-lifecycle` cut off `origin/main` `67924840`
- ✅ All Onda 2.B tests + new test files green (235/229 baseline)
- ✅ `lib/sta_connector/sta/rate_limiter.ex` exists with 2-bucket impl (`:query` + `:concurrent`)
- ✅ Client has duplicate inner check at `client.ex:757` — confirmed will be kept as pre-flight
- ✅ `Outbound.Worker` retry path: `handle_upload_error/2` (line 271) + `Process.send_after` (line 282)
- ✅ Manual sections cited in Task 0 are present in extract `/tmp/manual_sta.txt`

---

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

### Task 1 — 3-bucket RateLimiter façade

**TDD red:** Write `test/sta_connector/sta/rate_limiter_three_bucket_test.exs`:

- `acquire/2` with each new bucket atom (`:transfers`, `:queries`, `:status_changes`) succeeds when bucket has slots/tokens
- Buckets are independent: spamming `:queries` does not deplete `:transfers`
- `acquire/2` with old `:query` atom succeeds AND emits Logger.warning (deprecation)
- `acquire/2` with old `:concurrent` atom succeeds AND emits Logger.warning
- Property-equivalent: 12 concurrent `acquire(:transfers)` from 12 spawned tasks; exactly 10 succeed in <100ms, 2 wait
- `status/1` returns map with all 3 bucket counts
- Telemetry event `[:sta, :rate_limiter, :acquire]` emitted with `bucket` measurement

**TDD green:**

1. Create `lib/sta_connector/sta/rate_limiter/transfers.ex`:
   ```elixir
   defmodule StaConnector.Sta.RateLimiter.Transfers do
     @moduledoc "Bucket: 10 concurrent envio/recebimento per Manual §2.6 (line 190)."
     def max_concurrent, do: 10
   end
   ```

2. Same for `queries.ex` (`max_per_minute = 120`, Manual §2.6 line 192) and `status_changes.ex` (`max_per_minute = 10`, conservative per [STUDY-CONJECTURE] #2).

3. Modify `rate_limiter.ex`:
   - Replace `query_tokens`/`concurrent_slots` State fields with `buckets: %{transfers: ..., queries: ..., status_changes: ...}`.
   - Each bucket value is a `%{tokens: int, max: int, last_refill: int}` map (uniform shape; transfers refill is N/A — concurrent slots only).
   - New `acquire/2` clauses for each bucket atom; existing `:query`/`:concurrent` clauses become aliases that log deprecation + dispatch.
   - `release/2` → only valid for `:transfers` (concurrent slots release on completion); other buckets are token-bucket, no release.
   - Token refill ticks per-bucket: `:queries` refills 1 token / 500ms (= 2/s = 120/min); `:status_changes` refills 1 token / 6000ms (= 10/min).
   - Telemetry event on every acquire (success or wait or timeout).

**Commit message:** `feat(sta): 3-bucket RateLimiter façade per Manual §2.6 (Onda 2.C Task 1)`

**Estimate:** 3-4h (including retrofit of existing token-bucket logic).

---

### Task 2 — HTTP 410 per-operation mapping

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

```elixir
describe "HTTP 410 per-operation mapping (Manual §5.6 vs §6.1/§6.4)" do
  test "upload_file 410 → {:error, :protocol_expired} per §5.6"
  test "upload_part 410 → {:error, :protocol_expired} per §5.6"
  test "posicao_upload 410 → {:error, :protocol_expired} per §5.3 (inferred from §5.6)"
  test "request_protocol 410 → {:error, :protocol_expired} per §5.1 (inferred from §5.6)"
  test "download_part 410 → {:error, :file_unavailable} unchanged per §6.4"
  test "download_file_streamed 410 → {:error, :file_unavailable} unchanged per §6.1"
end
```

**TDD green:** Add 4 new clauses to `map_operation_error/2`:

```elixir
defp map_operation_error(:upload_file, 410), do: {:error, :protocol_expired}
defp map_operation_error(:upload_part, 410), do: {:error, :protocol_expired}
defp map_operation_error(:posicao_upload, 410), do: {:error, :protocol_expired}
defp map_operation_error(:request_protocol, 410), do: {:error, :protocol_expired}
```

Update `client_behaviour.ex` callback specs to include `:protocol_expired` in the error union for the 4 upload operations.

**Commit message:** `feat(sta): per-operation HTTP 410 mapping per Manual §5.6 vs §6.1/§6.4 (Onda 2.C Task 2)`

**Estimate:** 1h.

---

### Task 3 — HTTP 429 Retry-After parsing

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

```elixir
describe "HTTP 429 Retry-After (RFC 7231)" do
  test "429 with Retry-After: 30 → {:error, {:rate_limited, 30}}"
  test "429 without Retry-After → {:error, {:rate_limited, 60}} default per [STUDY-CONJECTURE] #1"
  test "429 with HTTP-date Retry-After → {:error, {:rate_limited, 60}} default + warning"
  test "429 with negative integer Retry-After → defaults to 60"
end
```

**TDD green:**

- Add status 429 case in `execute_request/3` BEFORE the generic non-2xx fallback.
- Add private `parse_retry_after/1` helper near `find_header/2`.

**Commit message:** `feat(sta): HTTP 429 Retry-After parsing per RFC 7231 (Onda 2.C Task 3)`

**Estimate:** 1h.

---

### Task 4 — Outbound.Worker auto-renew + delayed retry

**TDD red:** Extend `test/sta_connector/outbound/worker_test.exs`:

```elixir
describe "dispatch_upload_error/2 — Onda 2.C error dispatch" do
  test "{:rate_limited, n} → {:retry, n*1000}"
  test "{:rate_limited, 0} → {:retry, 0} (immediate retry)"
  test ":protocol_expired AND state.protocol_renewed=false → {:renew_protocol, message}"
  test ":protocol_expired AND state.protocol_renewed=true → {:fail, message containing 'after protocol renewal'}"
  test "any other error → {:fail, format_error(error)}"
end
```

**TDD green:**

1. Add `protocol_renewed: false` to State struct.
2. Add `dispatch_upload_error/2` pure function.
3. Modify `handle_upload_error/2` to call `dispatch_upload_error/2` and route the result.
4. Add `Outbound.clear_protocol/1` for the renewal path.

**Note on test scope per handoff §Out of scope #6:** the new tests cover the pure helper only — the GenServer integration tests for renewal flow + delayed retry are out of scope (would require DB setup + Mox stubs for Client). Pure helper coverage is enough to lock the dispatch contract.

**Commit message:** `feat(sta): Outbound.Worker dispatch_upload_error for protocol_expired + rate_limited (Onda 2.C Task 4)`

**Estimate:** 2-3h.

---

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

**Before commit on each task:**
- Per-file tests for the touched file: green.
- `mix compile` still produces exactly 5 baseline warnings.

**Before push/PR:**
- All 6 touchstone test files green per baseline counts (post Onda 2.B: client_test.exs at 86/6, others unchanged).
- All Onda 2.B-introduced new test files green (state_code_test 14/0, inbound/worker_test 3/0, outbound/worker_test 3/0 → grows in Task 4, sta_query_controller_test 16/0).
- New tests added: `rate_limiter_three_bucket_test.exs` (~7), `client_test.exs` 410-block (~6), `client_test.exs` 429-block (~4), `outbound/worker_test.exs` dispatch (~5).
- `git diff $(git merge-base origin/main HEAD)..HEAD --name-only | grep -v "^sta/"` is empty.
- Manual Verification gate satisfied (Task 0 inventory referenced in PR body).

**Acceptance criteria from rollout plan §Sub-PR 2.C (verbatim):**

- ✅ "Three independent buckets enforced and observable via telemetry" — Task 1 (telemetry events emitted on every acquire).
- ✅ "Protocol auto-renews on 410" — Task 4 (`:renew_protocol` dispatch + `Outbound.clear_protocol/1`).
- ✅ "429 backoff works without sleeping the caller's process indefinitely" — Task 4 (`Process.send_after` is non-blocking; the worker GenServer process doesn't sleep, just waits on the message).

---

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

Carried forward + new items deferred per (B) trim:

1. Onda 2.D — `/senhaws/senha` password management.
2. Onda 3 — leiautes corpus.
3. Onda 4 — identifier mapping in `core/`.
4. Real BCB smoke test — operator post-merge.
5. Telemetry metric definitions (vs. event emission) — Wave 11 P2 metrics framework will compose them from the events emitted in Task 1.
6. Admin auth enforcement — `:admin_api` pipeline still has the `# Future: Add admin authentication here` TODO.

**(B) trim deferrals:**

7. **`OutboundFile.protocol_expires_at` field + migration** — proactive 48h tracking. Reactive 410 handling already covers BCB-side correctness. Defer until an admin-UI consumer actually exists.
8. **Inbound.Worker `:rate_limited` handling** — inbound has its own RetryEntry-based retry budget; layering 429 sleep is a separate concern. Reactive failure path in Task 3 still works (the error propagates), but no auto-sleep until a consumer asks for it.
9. **Operations.upload_with_retry/4 retry-loop refactor** — today the loop has its own retry budget per attempt, blind to `:protocol_expired`/`:rate_limited`. The current PR routes errors through Worker, not Operations retry-loop. Worker test covers the dispatch. Operations refactor is its own hygiene PR.
10. **Removing the inner-Client rate check** ([STUDY-CONJECTURE] #4) — kept as pre-flight; removing is a separate hygiene PR.
11. **GenServer integration tests for Outbound.Worker** — pure-helper coverage only per handoff §Out of scope #6.

---

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

### Plan docs
- **Parent rollout plan:** `sta/docs/plans/2026-04-26-sta-conformance-rollout.md` §Sub-PR 2.C (lines 739-770), §Pattern: Manual Verification.
- **Onda 2.B plan (template):** `sta/docs/plans/2026-04-27-sta-onda2b-status-and-queries-full-admin.md`.

### Handoff docs
- **Predecessor handoff:** `sta/docs/handoff/2026-04-27-sta-onda2b-full-admin-complete.md`.
- **(Earlier 2.A handoffs):** `sta/docs/handoff/2026-04-26-sta-onda2a-followup-callers-wiring.md`, etc.

### Manual STA references
- `sta/Manual_STA_Web_Services.pdf` — v1.5 jul/2022.
- `/tmp/manual_sta.txt` — extracted text (regenerable via `pdftotext -layout`).
- §2.6 Limites de conexões (lines 183+ in extract) — concurrent + per-minute caps.
- §5 intro (line 267) — 48h protocol expiry.
- §5.6 errors table (line 564) — 410 = "protocolo cancelado".
- §6.1 errors table (line 653) — 410 = "arquivo não disponível".
- §6.4 errors table (line 738) — 410 = "arquivo não disponível".

### RFCs
- RFC 6585 §4 (HTTP 429 status code).
- RFC 7231 §7.1.3 (Retry-After header — delta-seconds OR HTTP-date form).

### Onda 2.A + 2.B merged PRs (cross-reference)
| PR | SHA | Title |
|---|---|---|
| #24 | `301503ec` | Onda 1 wire-format + ICP-Brasil chain |
| #30 | `3af17964` | Onda 2.A core |
| #34 | `ed03ce39` | Worker chunked-upload wiring |
| #36 | `100e5154` | Operations.resume_upload/3 |
| #43 | `99e6e57c` | Streaming download + ranged-resume mid-download |
| #46 | `1c10b7fa` | Caller-wiring bundle |
| #52 | `fbf4dac2` | upload_attempts retry-cap fix |
| #62 | `67924840` | Onda 2.B status & queries (C full+admin) |

---

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