---
description: Where untrusted input becomes a domain type, and the rules that hold there
globs: packages/core/src/trust/**,packages/core/src/adapters/**,packages/core/src/markets/**,packages/cli/src/statusline.ts,packages/cli/src/marketCache.ts,packages/cli/src/scheduleSlice.ts
alwaysApply: false
---

# The trust boundary

Feed responses and local cache files are untrusted input. They enter through
`packages/core/src/trust/` and leave as a domain type or as a stated reason they
could not. Nothing else builds a `Match` or a `MarketSignal` from raw input.

## Rules

- **An adapter FETCHES; it does not interpret.** Parsing lives in `trust/`
  (`trust/espn.ts` is the model). The adapter that also parsed is how `id` and
  `kickoff` ended up copied verbatim while every string beside them was cleaned.
- **Record refusal is local, not a provider outage.** `ProviderAdapter` returns
  plain arrays. Omit malformed, duplicate, or truncated records while keeping
  readable siblings usable and attributed to the provider. Throw into the
  degraded path only for transport/JSON failure, an unreadable envelope, or an
  incomplete payload with no usable records. Parser-local
  `BoundedList.complete` is diagnostic; never promote one refused record into a
  batch-wide blackout.
- **A standings table has a key, and one stated exception to "refusal is
  local".** `parseEspnStandings` derives a table's key from its RAW name by an
  anchored grammar (never from the sanitized label), withdraws a key two
  children claim from both, and counts the INVENTORY (did every child become a
  table?) apart from `partial` (rows missing from a table). A payload that
  holds tables nobody inspected (more than `MAX_TABLE_CHILDREN` children, or a
  child that carries a `children` value other than an empty list) is refused whole: that
  is not a record being refused, it is the claim "these are all the tables"
  that cannot be made. A league's one table is authorised per competition
  (`STANDINGS_SHAPE`), never inferred from a payload.
- **A composed window fails as a whole on what no sibling can stand in for.**
  `EspnAdapter.fetchWindow` is several responses and one answer. It throws when
  any part failed, when a part's envelope is unreadable, or when a part filled
  the request's `limit` (its tail is unknown); asked strictly (the default, and
  every caller that merges the bundle or keeps a slice), also when parts state
  two seasons. Asked `acrossSeasons` (a caller that merges nothing and keeps
  no slice: the live read, and off the bundle the dated read and the match
  read), two seasons are composed: the answer states no `season`, and
  `seasons` names both. The window's season is the one its stating parts agree
  on (a silent part does not veto); discovery's is a different rule (every
  month the same) and stays separate. Either way the composed season keeps its
  start and end date only when every stating part stated the same one
  (`agreedSeason`). A refused record, or one fixture in two
  parts, leaves the answer `complete: false` with `omitted` counting the
  records left out (`fetchMeta`; the parser counts at the boundary, 0 exactly
  when complete; counted over the responses asked, a month whole, so a record
  refused outside the span counts and the count bounds what the span may be
  missing). A caller that keeps a previous answer must carry that
  verdict: an incomplete answer never erases what it did not read. The
  interactive results carry it as the `partial` verdict: `next`, `bracket`,
  `today`, `live`, `match` (on the bundle and off it), the market surfaces,
  and their share cards and MCP twins (`AGENTS.md`, the window bullet, names
  the scope). The statusline, the hook and the cache do not: the cache's live
  slice carries no verdict. The dated read's `skeleton` (the bundled schedule
  was merged) and `served` (the ids the window held; the bundled match read
  states it too) are request-local, never cached: a surface reads them to say
  what an empty day means and to attribute the fixtures it displays
  (`dayAttribution`), and `served` rides in the structured twins as a plain
  field beside `source`, only beside `partial` and bounded to the shown
  fixtures (`servedExtras(result, shown)`).
  A caller that composes windows itself (discovery: a window per month) asks
  "no readable record" of the whole too: a window refused because its list
  held no readable record carries `ProviderError.noReadableRecord` and read
  nothing; an envelope nobody could read carries no such mark and still fails
  it.
- **Both paths end at the same constructor.** Live and cache reads call
  `sealMatch` / `sealMarketSignal`. When they had separate rules, each fix landed
  on one of them; `core/test/trust-parity.test.ts` asserts they agree, as a JSON
  round trip, because that is literally what the cache file is.
- **A `Team` has one constructor and one identity.** `sealTeam` builds every
  team — feed competitors, standings rows, cache records. `id` is the provider's
  stable id, namespaced (`espn:359`), kept only when it matches the grammar and
  dropped otherwise on every path alike. `code` is a display label: never
  matched against a shape, and not an identity (a real club is `O&M`; `CAR` is
  two clubs in one competition). The places that still select a team by its
  letters (`fixturesByTeam`, the statusline/hook filter, the team argument) are
  legacy World Cup behaviour, not a pattern to copy. `sameTeam` decides self-play: the same id, OR the
  same code and name — an id only ever adds a refusal. Selecting a team's own
  fixture is `isTeam`: when both carry an id, equal ids decide (a side with
  another club's id is never this club's, whatever its labels).
- **Response metadata rides on the result.** The season (or seasons) a response
  answered for, and the count of records it left out, are attached to the
  returned array (`adapters/meta.ts`), never stored on the adapter, where an
  overlapping call would overwrite it.
- **Text has roles.** `humanLabel` is prose — no controls, no format characters,
  **no emoji**, bounded by display columns *and* code points. `opaqueId` is
  checked against an exact grammar. `canonicalTimestamp` re-emits one form and
  refuses a date that does not exist. `productFlag` GENERATES the flag from the
  nation, and only for a nation: `sealTeam(raw, kind)` takes the competition's
  written team kind (`TEAM_KIND`), and a club gets no flag key at all. Both
  paths state the kind (the adapter its competition, every cache reader
  `teamKind(competition)`). Unstated, two defaults: the core constructor and
  the parsers read a team as a CLUB (`SealOptions.teamKind`, a parse with no
  `competition`); the ambient renderers (`renderPrompt`, `renderHook`) default
  through `defaultTeamKind`: the bundle's NATIONS, unless `defaultCompetition`
  is false, and then clubs. A season slug is read
  by the written stage grammar (`stageFromSlug`): bounded first, matched whole,
  `OTHER` with the provider's words (a 40-column label) for what it does not know.
- **Never accept a product glyph from input.** While flags travelled through the
  text filter it needed an emoji carve-out, and a carve-out without a grammar is
  a covert channel: TAG characters, variation selectors and ZWJ each rode through
  it. A `🏴` plus 42 tag characters is ONE two-column glyph spelling a full
  instruction sentence — invisible on a terminal, legible to a model reading
  `--json`.
- **Say which kind of failure it is.** `ParseResult` = `valid` /
  `definitive-none` / `malformed` / `ambiguous` / `unresolved`. For per-item
  market resolution, `valid`, `definitive-none` and `ambiguous` are stable and
  cacheable; `malformed` and `unresolved` must not become definitive negatives.
  A successful provider batch with one malformed sibling is still shared for
  the coalescing TTL: identical bytes will parse identically, and immediate
  refetches only waste egress and starve the readable records.
- **Enrichment completeness reaches the renderer.** Market cache/fetch helpers
  return `{ signals, complete }`; default-on annotations, dedicated tools,
  share cards, and structured output retain it. Only a complete empty batch may
  say "no signal"; an incomplete one warns and carries `marketComplete:false`
  or `complete:false`.
- **Bound the work, not just the output.** `takeBounded` slices *before* the
  per-record map. A surface reports `total`/`shown`/`truncated` from one
  `BoundedList`; no handler recomputes a count. `total` is exact only when
  `complete` is true, so incomplete hot-path scans use a nonnumeric `more`
  marker rather than guessing how many valid records remain. MCP response
  limiting preflights depth, width, entries, containers, and aggregate text
  before serialization or recursive shrinking; a byte cap alone does not bound
  the CPU and allocation spent discovering that a payload is too large.
- **Fail closed on ABSENCE.** A missing field must be at least as rejecting as a
  wrong one. Five market gates were `x != null && ...`, so the more malformed
  payload was the more successful one.

## Changing this area

Add the property to `core/test/trust-properties.test.ts` (table-driven off each
type's declared key list, so a new field fails by default), and **verify it goes
red** with the rule reverted — a property test you have not made fail is pinning
nothing. If it is a claim `SECURITY.md` makes, cite the test there;
`core/test/security-claims.test.ts` fails if a citation rots.
