# `ExSandbox.Egress.Registry`
[🔗](https://github.com/FoundryStack/ex_sandbox/blob/v1.2.0/lib/ex_sandbox/egress/registry.ex#L1)

Holds each running sandbox's egress policy, keyed by its source /30
(005 T060a1/T060a6, `005-FR-011a`–`FR-011e`).

## The one way this design can leak across tenants

Everything else here is protected by topology: no sandbox has a route to any
other, and identity is the kernel's view of the source address. Those hold
continuously. **Address reuse does not** — it is a lifecycle race, and it is
the single point where one tenant can inherit another's allowlist.

The sequence is:

  1. sandbox A holds `10.0.0.0/30` with A's allowlist,
  2. A is destroyed and its /30 returns to the pool,
  3. sandbox B is provisioned and assigned `10.0.0.0/30`,
  4. B connects — and if A's entry is still registered, **B gets A's
     allowlist**.

⚠️ Note what makes this dangerous rather than merely wrong: every outward
check still passes. B reaches destinations, denied destinations are refused,
the policy is not editable from inside. The allowlist being enforced is simply
the *wrong tenant's*. Nothing outward-facing distinguishes that from correct
operation, which is why it is enforced structurally below rather than by an
ordering convention in `destroy`.

## The invariant

**A /30 is not available for assignment until its policy entry is gone.**
`release/1` deletes the entry and only then returns the /30 to the pool;
`assign/2` refuses a /30 that still carries one. Both are enforced here rather
than left to callers, because the correct ordering in a `destroy` callback is
exactly the kind of thing a later refactor reorders without knowing why it was
written that way.

## Resolved answers live here too, and that is a lifecycle decision

`029 T016` makes a **hostname** allowlist entry match by consulting what this
sandbox resolved that name to. Those answers are per-sandbox state with
exactly the same reuse hazard as the policy above: sandbox B assigned A's /30
while A's answers survive would inherit A's *name bindings*, which is the same
cross-tenant error one layer down and just as invisible from outside.

⚠️ **So they are not a second store.** Holding them here means `assign/3`'s
refusal and `release/2`'s delete cover both at once, and there is no second
ordering for a later refactor to get wrong. A separate `Resolutions` module
would have been tidier to read and would have re-opened the one leak this
module exists to close.

A record for an unregistered /30 is **refused**, not created. Creating one
would file answers under a sandbox that does not exist, where nothing ever
releases them.

# `refusal`

```elixir
@type refusal() ::
  {:still_registered, ExSandbox.Egress.Policy.source_key()} | :pool_exhausted
```

Why an assignment was refused. Distinguishable by construction.

# `assign`

```elixir
@spec assign(
  ExSandbox.Egress.Policy.source_key(),
  [ExSandbox.Egress.Policy.destination()],
  GenServer.server()
) :: :ok | {:error, refusal()}
```

Assigns `source_key` to a sandbox with the given allowlist.

Refuses with `{:error, {:still_registered, key}}` when the /30 still carries a
previous tenant's policy — see the invariant above.

# `child_spec`

Returns a specification to start this module under a supervisor.

See `Supervisor`.

# `lookup`

```elixir
@spec lookup(ExSandbox.Egress.Policy.source_key(), GenServer.server()) :: [
  ExSandbox.Egress.Policy.destination()
]
```

Returns the allowlist for `source_key`, or `[]` when none is registered.

⚠️ `[]` rather than an error, and rather than `nil`. An unregistered source
must be *denied*, and `Policy.permits?/2` denies `[]` — so the miss path and
the deny path are the same path. Returning an error would invite a caller to
handle it, and the tempting handling is to let the connection through while
logging.

# `record_resolution`

```elixir
@spec record_resolution(
  ExSandbox.Egress.Policy.source_key(),
  String.t(),
  [:inet.ip_address()],
  GenServer.server()
) :: :ok | {:error, :unknown_source}
```

Files the addresses this sandbox resolved `name` to (`029-FR-012`).

Refuses with `{:error, :unknown_source}` for a /30 carrying no policy — see
the moduledoc on why an entry is never created here.

⚠️ Answers **accumulate** rather than replace. A name legitimately resolves to
a different member of a rotation on each query, and a connection opened
against the first answer while the second is being recorded must not be
refused for it. The set is bounded by `release/2`, which is the sandbox's own
lifetime.

# `registered?`

```elixir
@spec registered?(ExSandbox.Egress.Policy.source_key(), GenServer.server()) ::
  boolean()
```

True when `source_key` currently carries a policy.

# `release`

```elixir
@spec release(ExSandbox.Egress.Policy.source_key(), GenServer.server()) :: :ok
```

Removes the policy for `source_key`. Idempotent (`003-FR-013`).

# `resolutions`

```elixir
@spec resolutions(ExSandbox.Egress.Policy.source_key(), GenServer.server()) :: %{
  optional(String.t()) =&gt; MapSet.t(:inet.ip_address())
}
```

What this sandbox resolved, as `%{name => MapSet.t(address)}`.

⚠️ `%{}` on a miss, for the same reason `lookup/2` answers `[]`: the miss path
and the deny path must be one path. An empty map permits no name, so a
sandbox that never resolved anything reaches no hostname entry.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
