trust-boundary ยท diff

git:20261004.bef388a to git:20261004.f6b4cf9

1 added, 1 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 statusline/hook filter, the team argument) are
+ 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
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.