# `DpExchange.Core.Notice`
[🔗](https://github.com/DistortionPoint/dp-exchange-core/blob/v0.3.120/lib/dp_exchange/core/notice.ex#L1)

What a venue package says about **itself** — distinct from what a venue says about the
market, and never carrying market data.

A consumer subscribes; the package never holds a consumer's function. That inversion is
the whole design: a package that was handed a sink would be deciding what happens next,
which is the consumer's decision, and would be holding a reference into an application
it must know nothing about.

| Channel | Subject | Example |
|---|---|---|
| the data stream | what the **venue** says about the **market** | `%Types.Quote{}` |
| notices | what the **package** says about **itself** | `%Notice{kind: :credentials_rejected}` |

A consumer may want the second without the first. A monitoring process that never
touches market data still needs to know a venue's credentials expired.

## Telemetry is measurement; a notice is a condition

The two overlap enough that the line has to be stated rather than discovered.
**Telemetry is what you aggregate. A notice is what you act on.** A request duration is
a metric. "Your API key was rejected" is not, and it must not be delivered by a
mechanism whose handlers run inside the emitting process and whose delivery is
legitimately lossy.

## A notice is a prompt to re-read, never the record

**Delivery is not guaranteed and a consumer's correctness must not depend on it.** This
is not a caveat, it is the contract: reporting on the work must never become the reason
the work does not happen, so a notice that cannot be delivered is dropped rather than
retried or blocked on.

The failure this warns against has already happened once, in the application these
packages came from. Three cached copies of a symbol's status were kept in step with
fire-and-forget casts — and a cast to a dead or restarting process returns `:ok` and is
silently dropped. Two symbols suspended at 03:14 and 03:27 UTC opened fresh positions at
21:46, because the message that would have stopped them had vanished with a process
nobody noticed had restarted.

So: a `:catalog_change` notice is a reason to re-read `list_instruments/1`, not the
authority that a pair was delisted. A `:coverage_change` is a reason to call
`coverage/1`. Treat a notice as a nudge and the dropped-message case degrades to
latency; treat it as the record and the dropped-message case is silent, wrong state.

## It never carries credentials

Not the key, not a fragment of it, not a redacted form. These packages are public and an
operational notice is exactly the sort of thing that gets pasted into an issue. A
credential notice names *which* credential failed and how — never its value.

## The kinds

### Link

`:link_up`, `:link_down`, `:link_reconnecting`, `:link_recovered` — stated **without
naming the transport**. "The venue link is down" is the fact; whether that link is a
WebSocket, an MQTT session or a polling loop is package-internal and not a consumer's
concern.

### Credentials

`:credentials_rejected`, `:credentials_expiring`, `:session_refresh_failed`. Close to
load-bearing: a consumer that cannot learn its keys stopped working finds out from the
absence of data, which is the slowest possible signal.

### Pressure

`:rate_limited` — sustained limiting, or a venue returning `429` past the point where
retry is working. A single `429` is a metric and belongs in telemetry; a venue that will
not stop returning them is a condition.

### Coverage

`:coverage_change` — what makes `coverage/1` **pushable rather than pollable**. Drawn
directly from an incident: 325 symbols subscribed and confirmed, 174 actually
delivering. A drop from 325 to 174 is an event, not a number to be discovered by asking.

A feed process that crashed and was restarted by its supervisor, and resumed the symbols
and subscribers it had accepted, says so with `:coverage_change` and `details.reason:
:feed_restarted` (with `details.symbols`): deliveries paused, nothing was dropped silently,
and the consumer may want to re-read state it derived from the stream (tick AO, all venues).

### Catalog

`:catalog_change` — a pair added, removed, or moving `:tradable` → `:delisted`. The one
kind that originates at the venue rather than in the package, and it belongs here rather
than on the data stream because it is not market data: a price is what the market says,
a delisting is a change in the venue's own shape. A consumer holding a pair needs to
know it stopped trading even if it never subscribed to a single quote. It also makes the
catalogue pushable rather than diffable, which matters most where diffing is worst — a
millions-of-instruments venue cannot be re-pulled on a timer to spot one delisting.

**Two honesty constraints.** Most venues do not *announce* a delisting; the pair simply
stops appearing, so the package learns it by diffing internally and **must say so** —
`observed: true` rather than announced. And a vanished pair is not evidence of a
delisting: `DpExchange.Core.Instrument` resolves unrecognised input to `:unknown`, never
to `:tradable`, and the two must not be conflated.

### Refusal and quality

`:refusal` — a symbol the venue will not carry. `:data_quality` — a payload that did not
parse.

### Degradation

`:degraded` — the venue is answering, but from a slower path than usual.

## Which kind a situation is

The kinds above are not interchangeable, and a venue picks by what happened:

  * A **sequence gap**, or a depth/book update that cannot be decoded, is `:data_quality`.
    It is not `:degraded`.
  * A venue **refusing a subscription** is `:refusal`, carrying `details.symbols` (a
    list of the symbols refused). Not `:degraded`, even when the frame that said so names
    a subscription rather than a symbol.
  * `:degraded` is only for "a slower path is in use".
  * `:credentials_rejected` is emitted wherever a stream **or a poll** is refused for
    authentication (a 401 or 403), not only on a socket. A poll that only narrows its
    coverage on a 401 hides the cause. It is latched per feed and cleared on the next good
    poll.

## A poll route's link notices are a pair

`:link_down` is closed by `:link_up`. A poll route (a venue's own feed or fallback poll,
built on `Core.PollingFeed` or not) that emits a `:link_down` notice because a read did not
answer or crashed emits `:link_up` **once** (latched) on the first good poll afterwards,
with the same
`details.symbols` shape the venue's streaming `:link_up` uses. A `:link_down` with no
closing `:link_up` leaves a consumer waiting for a recovery it is never told about.

## `details.reason` is an atom or a tagged tuple, `details.message` is the text

`details.reason` is something a consumer can **match**: an atom (`:unauthorized`) or a
tagged tuple (`{:http_status, 503}`), never free text and never a venue's body. Venue free
text goes in `details.message`, bounded (see the cap on a default message below). The
top-level `:message` field is the notice's own line; `details.message` is the venue's
wording, kept apart so that matching on `reason` never depends on a venue's phrasing.

# `fault`

```elixir
@type fault() ::
  {:not_a_notice, term()}
  | {:reason_not_matchable, term()}
  | {:message_not_text, term()}
  | {:symbols_not_a_list, term()}
  | :refusal_without_symbols
  | :data_quality_without_reason
```

One way a notice breaks the documented rules. See `conformance_faults/1`.

# `kind`

```elixir
@type kind() ::
  :link_up
  | :link_down
  | :link_reconnecting
  | :link_recovered
  | :credentials_rejected
  | :credentials_expiring
  | :session_refresh_failed
  | :rate_limited
  | :coverage_change
  | :catalog_change
  | :refusal
  | :data_quality
  | :degraded
```

What the notice is about.

Deliberately a closed set. An open one would let each venue package invent its own
vocabulary, which is the drift a shared contract exists to prevent — a consumer would be
back to matching on venue identity to know what a notice meant.

# `provider`

```elixir
@type provider() :: atom() | String.t()
```

Which venue this is about.

Normally the venue's own atom — `:coinbase`, `:schwab` — and a consumer routing notices by
venue should be able to match on that. A `String.t()` is permitted because a feed that is
not a venue can raise one too: `Core.PollingFeed` names itself here when its caller has not
told it which venue it polls for, and its own default is the string `"polling-feed"`.

**So match defensively, or make sure the poll is configured.** A venue package should pass
`provider:` to `PollingFeed` so both halves of that package agree; one that does not will
emit `:venue` from its `Feed` and `"its-label"` from its poll, for the same venue, and a
consumer matching only the atom will quietly miss half its notices.

# `severity`

```elixir
@type severity() :: :info | :warning | :error
```

How much a consumer should care. Not a log level — a call to action.

# `t`

```elixir
@type t() :: %DpExchange.Core.Notice{
  at: DateTime.t(),
  details: map(),
  kind: kind(),
  message: String.t() | nil,
  provider: provider(),
  severity: severity()
}
```

# `conformance_faults`

```elixir
@spec conformance_faults(term()) :: [fault()]
```

The rules in this module's documentation that `new/1` cannot enforce, checked on a built
notice. `[]` is conforming. Pure: it never raises and never changes the notice.

`new/3` deliberately accepts these shapes, because it runs inside venue feeds that must not
crash while reporting a fault. Conformance (group 45 of `DpExchange.Core.AdapterContract`)
is where they are held instead.

  * `{:reason_not_matchable, value}` - `details.reason` is present and is not an atom (other
    than `nil` and booleans) or a tuple whose first element is an atom. Venue free text in
    `reason` is the fault: text goes in `details.message`.
  * `{:message_not_text, value}` - `details.message` is present and is not a binary.
  * `{:symbols_not_a_list, value}` - `details.symbols` is present and is not a list.
  * `:refusal_without_symbols` - a `:refusal` whose `details.symbols` is absent.
  * `:data_quality_without_reason` - a `:data_quality` with no matchable `details.reason`
    (a consumer matches the kind of fault on `reason`; it has nothing else to match).

Faults are returned in the order above, each at most once.

# `kinds`

```elixir
@spec kinds() :: [kind()]
```

Every kind this contract admits.

A venue package must not invent its own: a consumer matching on kinds would be back to
matching on venue identity to know what one meant.

# `new`

```elixir
@spec new(kind(), provider(), keyword()) :: t()
```

Builds a notice.

`:at` defaults to now, which for a notice is correct: unlike market data there is no
venue event time to preserve — the package is reporting on itself, and the moment it
noticed *is* the fact.

## Raises

On an unknown `kind`, because a typo would otherwise become a notice no consumer
matches on and no one ever sees. On an unknown `severity` for the same reason — it is
"not a log level, a call to action," so a value outside `:info | :warning | :error` is
a caller mistake, not a value to pass through. And on **any credential-shaped key in
`details`**: these packages are public, notices get pasted into issues, and a leak is
not fixed by a later release.

## `:severity`, `:at` and `:details` read through `DpExchange.Core.Config.opt/3`, not `Keyword.get/3`

The same trap this family has paid for repeatedly elsewhere (see `DpExchange.Core.Config.opt/3`'s own
moduledoc): a venue building `opts` from its own forwarded options, or from a value it
computed and got `nil` back from in an edge case, hands this constructor a PRESENT key
with a `nil` value rather than an absent one. `Keyword.get/3` only substitutes its
default for an absent key, so `severity: nil` used to build a `%Notice{severity: nil}`
that silently violated its own typespec and was never caught — `severity` was not
validated at all before this fix, unlike `kind`. `at: nil` used to build
`%Notice{at: nil}`, violating `@enforce_keys`' own non-nil promise the same way a
decode bug does for `Core.Types.*` (see `Core.Types.Validate`). `details: nil` used to
raise "must be a map, got nil" for what is, from a forwarding caller's side, simply an
unset optional field — the same shape `DpExchange.Core.Config.opt/3` exists to treat as absent
everywhere else in this family.

## Examples

    iex> notice = DpExchange.Core.Notice.new(:link_down, :some_venue, severity: :warning)
    iex> {notice.kind, notice.severity}
    {:link_down, :warning}

    iex> DpExchange.Core.Notice.new(:not_a_real_kind, :some_venue)
    ** (ArgumentError) unknown notice kind :not_a_real_kind

    iex> DpExchange.Core.Notice.new(:link_down, :v, details: %{api_key: "sk-live-123"})
    ** (ArgumentError) notice details carry credential-shaped keys: [:api_key]. A notice names WHICH credential failed, never its value — these packages are public and notices get pasted into issues

    iex> DpExchange.Core.Notice.new(:link_down, :v, severity: :critical)
    ** (ArgumentError) unknown notice severity :critical — must be one of [:info, :warning, :error]

---

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