trust-boundary ยท diff
git:20261004.f6b4cf9 to git:20261009.810ba09
2 added, 130 removed. Audit A to A.
---
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 ambient pick's `CLAUDINHO_TEAM` code: a preference, the picked match first and the others kept, 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
+ 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.
- 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.
+ The rules of this area are `AGENTS.md` "Conventions" (canonical). Their narration, and what this rule stated that no bullet does, are in `guides/conventions/trust-boundary.md`; the parts on composed windows, team identity and bounds are in `guides/conventions/off-bundle-reads.md`, `guides/conventions/stage-and-teams.md` and `guides/conventions/surfaces.md`.