# Agent Protocol **Server:** secedgar-mcp-server **Version:** 0.19.1 **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.22` **Engines:** Bun ≥1.4.4, Node ≥24.0.0 **MCP SDK:** `@modelcontextprotocol/server` ^2.4.0 **Zod:** ^4.6.7 Query SEC EDGAR filings, XBRL financials, and company data through MCP. Read-only, no API keys required. Full design: `docs/sec-edgar-mcp-design.md`. > **Read the framework docs first:** `node_modules/@cyanheads/mcp-ts-core/CLAUDE.md` contains the full API reference — builders, Context, error codes, exports, patterns. This file covers server-specific conventions only. --- ## Core Rules - **Logic throws, framework catches.** Tool/resource handlers are pure — throw on failure, no `try/catch`. Plain `Error` is fine; the framework catches, classifies, and formats. Use error factories (`notFound()`, `validationError()`, etc.) when the error code matters. - **Use `ctx.log`** for request-scoped logging. No `console` calls. Attach operation-specific fields with `withExtra(ctx, { … })` — `RequestContext` is closed, so a bare `{ ...ctx, field }` spread no longer type-checks. - **Use `ctx.state`** for tenant-scoped storage. Never access persistence directly. - **Ask for missing input** with `return ctx.requestInput(...)`, reading the answers back through `ctx.inputs` when the handler is re-entered. Never await a caller mid-handler. - **Secrets in env vars only** — never hardcoded. - **Cut noise.** Add only what earns its place: no speculative generality, no guards for states the framework already prevents (Zod-validated params, classified errors), no abstraction until a third caller proves it, no option nothing sets. - **Close the loop on issues.** When implementing work tracked by a GitHub issue, comment on the issue with what landed and close it. Do both — a comment without a close leaves stale issues open; a close without a comment leaves no record of what shipped. The comment is for future readers — state the concrete changes, not the conversation that produced them. --- ## What's Next? When the user asks what's next or needs direction, suggest options based on the current project state. Common next steps: 1. **Add tools/resources/prompts** — scaffold new definitions using the `add-tool`, `add-app-tool`, `add-resource`, `add-prompt` skills 2. **Add services** — scaffold domain service integrations using the `add-service` skill 3. **Add tests** — scaffold tests for existing definitions using the `add-test` skill 4. **Field-test definitions** — exercise tools/resources/prompts with real inputs using the `field-test` skill, get a report of issues and pain points 5. **Run the `security-pass` skill** — audit handlers for MCP-specific security gaps: output injection, scope blast radius, input sinks, tenant isolation 6. **Run the `polish-docs-meta` skill** — finalize README, CHANGELOG, metadata, and agent protocol for shipping 7. **Run the `maintenance` skill** — investigate changelogs, adopt upstream changes, and sync skills after `bun update --latest` Tailor suggestions to what's actually missing or stale — don't recite the full list every time. --- ## MCP Surface ### Tools | Name | Description | Key Inputs | |:-----|:------------|:-----------| | `secedgar_company_search` | Find companies and retrieve entity info with optional recent filings | `query`, `include_filings?`, `forms?`, `filing_limit?`, `filed_after?`, `filed_before?` | | `secedgar_search_filings` | Search EDGAR filings since 1993 — full-text (2001+) plus archive-backed browse for pre-2001 ranges | `query?`, `forms?`, `filed_after?`, `filed_before?`, `limit?` | | `secedgar_get_filing` | Fetch a specific filing's metadata and document content | `accession_number`, `cik?`, `content_limit?`, `document?`, `offset?`, `section?`, `find?` | | `secedgar_get_financials` | Get historical XBRL financial data for a company | `company`, `concept`, `taxonomy?`, `period_type?`, `unit?`, `limit?` | | `secedgar_get_snapshot` | One-call financial profile — latest value of every supported concept from a single companyfacts read | `company`, `taxonomy?`, `period_type?` | | `secedgar_get_material_events` | 8-K filings with item codes decoded, filterable by item | `company`, `items?`, `filed_after?`, `filed_before?`, `limit?` | | `secedgar_get_insider_transactions` | Form 4 / 4-A insider transactions parsed from ownership XML | `company`, `transaction_type?`, `limit?`, `filed_after?`, `filed_before?` | | `secedgar_get_institutional_holdings` | 13F-HR quarterly institutional holdings parsed from the information table, composed with same-period 13F-HR/A amendments | `company`, `quarter?`, `limit?`, `offset?`, `consolidate?` | | `secedgar_find_holders` | Reverse 13F lookup — which institutional managers reported holding an issuer | `issuer`, `cusip?`, `quarter?`, `limit?` | | `secedgar_get_beneficial_owners` | 5%+ blockholders of an issuer from structured SCHEDULE 13D/13G XML | `issuer`, `form_kind?`, `include_amendments?`, `limit?` | | `secedgar_get_fund_holdings` | Fund portfolio holdings from the quarterly NPORT-P report | `fund`, `series_id?`, `report_date?`, `limit?`, `offset?` | | `secedgar_fetch_frames` | Fetch SEC XBRL frames for one concept × one period across all reporting companies | `concept`, `period`, `taxonomy?`, `unit?`, `limit?`, `offset?`, `sort?` | | `secedgar_compare_companies` | Period-aligned comparison of 2-10 named companies across 1-8 concepts; a longer list (up to 100) is answered to the cap, the rest returned unread in `deferred_companies` / `deferred_concepts` | `companies`, `concepts`, `taxonomy?`, `period_type?`, `periods?` | | `secedgar_search_concepts` | Discover supported XBRL concept names or reverse-lookup a raw tag | `search?`, `group?`, `taxonomy?` | | `secedgar_dataframe_describe` | Describe canvas dataframes by `name` with provenance, TTL, and schema; the nameless listing is refused over HTTP with auth none | `name?` | | `secedgar_dataframe_query` | Run a single-statement SELECT across dataframes (DuckDB SQL) | `sql`, `preview?`, `register_as?` | | `secedgar_dataframe_drop` | Drop a canvas dataframe by name. Opt-in via `EDGAR_DATAFRAME_DROP_ENABLED=true` | `name` | ### Resources | URI | Description | |:----|:------------| | `secedgar://concepts` | Common XBRL financial concepts grouped by statement, mapping friendly names to XBRL tags | | `secedgar://filing-types` | Common SEC filing types with descriptions, cadence, and use cases (NPORT-P included), plus the 8-K item-code decode tables for both numbering regimes | ### Prompts | Name | Description | |:-----|:------------| | `secedgar_company_analysis` | Guides structured analysis of a company's SEC filings | --- ## Domain Notes - **Rate limit:** 10 req/s per IP. Every SEC request goes through one process-wide `createPacer` queue in `EdgarApiService` that spaces request *starts* `1000 / EDGAR_RATE_LIMIT_RPS` ms apart, with no concurrency cap and no wait budget — an ordinary burst drains at the configured rate and is never shed, and this server's own traffic can never exceed it, so `rateLimitRps` stays at 10 and headroom for a co-tenant on the outbound IP is the operator's call via `EDGAR_RATE_LIMIT_RPS`. A 429 is **not** retried: SEC holds the block until the request rate has stayed under the threshold for ten minutes and every request sent meanwhile restarts that clock, so the ~3s retry budget (`MAX_RETRIES` 3 × `BASE_BACKOFF_MS` 1000, exponential) cannot outlast it (#112). Instead a 429 closes the service's **block gate** for `EDGAR_RATE_LIMIT_COOLDOWN_SECONDS` (default 600). While it is closed nothing is sent: a call fails locally as `RateLimited` with `retryAfter` set to the whole seconds remaining (never below 1), and the gate is re-read at dispatch, so a call already queued in the pacer when the 429 landed — or a 5xx, no-response, or body-failure retry sleeping through its backoff — is refused too. The first call after the cool-down is sent alone as a probe while every other call waits on it: any HTTP answer but a 429 reopens normal pacing, a probe 429 closes the gate for the full cool-down again, and a probe that gets no HTTP answer (a network error, or no headers within the per-attempt cap) proves nothing, so the next call probes in its place — and every call that waited on it counts that probe as one of its own no-response attempts, so calls queued behind a silent SEC each settle within their own three-attempt ladder instead of probing in turn, 15 s apiece (#173). **The gate lives in the service, not in the pacer's `cooldown` option** — that gate holds queued calls until it reopens and then releases them all at the pacing rate, so it can neither refuse locally nor keep the reopening to one request. The upstream 429 and the local refusal carry one `data.reason: 'rate_limited'`, declared (`thrownBy: 'service'`, `retryable: true`) on every tool that reaches SEC, plus `data.retryable: true` — `ctx.fail` copies a contract entry's `retryable` onto `data`, a service throw does not, so both throw sites set it themselves (#122) — and one recovery hint naming SEC's ten-minute window and the seconds to wait; SEC's HTML block page stays out of the payload: the 429's body is cancelled unread (#188). Reads the local mirror can answer keep answering during a block: their live reads (the ticker file, a filer's submissions or point read, an open period's frame) are refused locally like any other, and the mirror answers in their place (Local mirror below). 500/502/503/504 still retry with the backoff, and so does an attempt that gets no HTTP response at all — a `fetch` rejection (Bun sets the transport code on the error itself, Node's undici on its `cause`) or no response headers within `ATTEMPT_HEADERS_TIMEOUT_MS` (15 s from dispatch; pacer queue time and the body read are outside it) (#173). Each tool call's `CallScope` carries a failure budget, `CALL_FAILURE_BUDGET_MS` (40 s, `call-scope.ts`): the time SEC spends failing the call — unanswered-attempt and 5xx waits, backoff, body-idle time — is charged to it and healthy progress never is, attempt and body-idle waits are clamped to what is left, a backoff is slept only while the budget outlasts it, and once it is spent no further attempt or backoff starts and the request fails as `sec_unreachable` or, after a 5xx, `sec_unavailable`, so a call against an unresponsive SEC settles inside the MCP client's 60 s default; the caller's `ctx.signal` reaches every pacer wait, fetch, body read, and backoff, and a cancel rethrows untouched, never retried or counted as an SEC failure (#179). Every body is read inside that ladder, in `rawFetch`'s one body reader (`readBody`), so a 2xx whose body fails after its headers — the connection breaks off, invalid chunk framing or compression, or no bytes for `BODY_IDLE_TIMEOUT_MS` (15 s between reads, never across the whole body, so a body still arriving is never cut) — retries the same way; a probe whose headers arrive has already reopened the gate, and only its own call retries the body. The error body is read there too: a non-retryable status's or a last-attempt 5xx's body is captured through `readBody` (`errorBodyHead`: the first 500 bytes, `…` when cut short, never a partial code point) as a log-only diagnostic — it rides the error's `cause`, whose `data` the call's failure log record writes, never the error's own `data`, which the caller receives (#244) — so one that stalls after its headers ends at the 15 s idle limit or the budget left — keeping what arrived, its wait charged to the budget — and the caller's cancel ends it at once as cancellation (#202). A body the ladder has no use for — a 404's, a retried 5xx's (released before its backoff), a 429's, an answer landing as the caller cancels — is cancelled unread (#188); an error a spent budget ends with from a retried 5xx therefore logs no body. A 200 that reads whole but is not JSON where JSON is expected fails on its one request, unretried (#181). EDGAR full-text search answers a failure of its search backend with a 200 whose JSON carries `errorType` (its OpenSearch client's error class) in place of hits; `searchFilings` and `resolveEntityByName` opt in to classifying that body (`ReadOptions.backendFailure`), so `ConnectionError`, `TimeoutError`, and `NoLivingConnectionsError` fail the attempt on this ladder like a 5xx — same attempts, backoff, block-gate and pacer re-admission, budget, and cancel — and end as `efts_degraded_response` with `retryable: true`, while any other `errorType` (a `ResponseError` is the cluster refusing the request) fails on its one request with `retryable: false`; both carry `upstreamErrorType` and `upstreamErrorMessage`, bounded like the error body, never the body's `trace` or `requestId`, and the entity lookup throws instead of reading the body as no match (#206). So does a submissions document or archive page whose JSON lacks what its readers index — `filings.recent`'s (or the page's) six columns `accessionNumber`, `filingDate`, `form`, `primaryDocument`, `primaryDocDescription`, `reportDate` as arrays, an array `filings.files`, array `tickers` and `exchanges` — checked in `getSubmissions` / `fetchArchivePage` before the cache write, so nothing is cached and the next call refetches (#186). Empty arrays pass; lengths and optional columns are not checked. When the last attempt gets no complete, readable answer — whatever the earlier ones got — the call fails as `ServiceUnavailable` with `data.reason: 'sec_unreachable'`, `retryable: true`, `url`, and a hint, declared beside `rate_limited` on the same tools; the message says which case (no response, a body that failed to read, a body that is not the JSON the request reads) and names host, attempts, and the transport code or the timeout, never the request URL, and the runtime's own text rides `cause` to the log only (#173, #181). When the last attempt answers 500, 502, 503, or 504 — whatever the earlier ones got — the call fails as `ServiceUnavailable`, a 504 too (it is SEC's gateway failing, as #173 codes this service's own header timeout; `data.status` keeps 504), with `data.reason: 'sec_unavailable'`, `retryable: true`, and a hint that SEC failed on its side and the same call can be retried in a minute, declared beside `sec_unreachable` on the same tools; its message reads like `sec_unreachable`'s (`SEC EDGAR (www.sec.gov) answered HTTP 503 on the last of 3 attempts.`, or `… on attempt 2 of 3; retries stopped at this call's 40 s budget for SEC failures.`), and the rest of `data` is the status mapping's: `url`, `status`, `statusText`, `statusCode`, and `retryAfter` when SEC sends `Retry-After` (#233), with SEC's body on the `cause` chain for the log (#244). `createApp({ teardown })` disposes the pacer through `disposeEdgarApiService()` (#116) - **Parameter names:** one name per concept across tools — `company` (the entity a tool reads; on `get_institutional_holdings` that is the 13F filer), `filed_after` / `filed_before` (inclusive filing-date bounds), `forms`. Every tool carrying one of those keys declares `inputAliases` for the other spellings in use — `ticker`, `cik`, `ticker_or_cik` → `company`; `start_date`, `date_from` → `filed_after`; `end_date`, `date_to` → `filed_before`; `form_types` → `forms` — so a caller carrying a name from one tool to the next is not rejected (#115). An alias is declared only where it maps to exactly one key of that tool and its value shape fits: a singular `form_type` cannot fill the `forms` array, so it is left out rather than widening the schema. A tool whose only company input carries another name takes the same company-identifier spellings onto it — `company`, `ticker`, `cik`, `ticker_or_cik` → `issuer` on `find_holders` and `get_beneficial_owners`, and those plus `name` and `search` → `query` on `company_search` — since the key it moves into has no other reading there; the canonical names stay, because renaming them would break existing callers (#152). `fund` (a fund or series) and `companies` (an array) are not aliased. `company_search`'s `filing_limit` → `limit` on `search_filings`, `get_material_events`, and `get_beneficial_owners`, whose one count input counts filings; rewritten before validation, it meets `limit`'s own cap and bound in either `EDGAR_CLAMP_OVERSIZED_LIMITS` mode. A `limit` counting managers, transactions, positions, companies, or data points takes no such alias, and `company_search` takes no `limit` alias, since there it would read as a count of companies (#212). Every tool with the date pair accepts either bound alone. `search_filings` fills the missing one before routing, since EDGAR full-text search ignores a one-sided custom range: a lone `filed_after` runs through today (UTC), a lone `filed_before` starts at 2001-01-01, or at 1993-01-01 when it falls before 2001, and the full-text path discloses a filled window in `effectiveQuery` as `(filed to )` (#185) - **User-Agent required:** `"AppName contact@email.com"` on every SEC request or IP gets blocked - **CIK zero-padding:** the `data.sec.gov` APIs (submissions, companyfacts, companyconcept) require the 10-digit zero-padded CIK (`String(cik).padStart(10, '0')`). Archive paths under `www.sec.gov/Archives/edgar/data/` take it unpadded — SEC answers the padded path with a 301, and `fetch` follows it inside the same pacer slot, so every archive read would cost two requests. `filingArchiveUrl` (exported from `edgar-api-service.ts`) strips the zeros for all seven archive reads and for `get_filing`'s `filing_url` (#156) - **ETF/mutual-fund tickers:** `loadTickerCache` fetches `company_tickers_mf.json` alongside `company_tickers.json` and merges fund symbols (VOO, SCHD, JEPI…) into `byTicker`. Fund CIKs are registrant trusts (1:many with series), so fund rows go into `byTicker` only — never `byCik`, whichever source they come from. Resolved fund matches carry `seriesId`/`classId` from the live MF file, the only source of either. With the mirror on and `EDGAR_MIRROR_FALLBACK_LIVE` on, the equity rows come from the live `company_tickers.json` every TTL as with no mirror, the live MF file is merged in, and the mirror's fund rows join behind it, so a fund symbol resolves when the live fund file lacks it or fails to load, and when the mirror predates MF ingestion (#43, #234). When `company_tickers.json` fails there — any failure, a rate-limit refusal included — `loadTickerBase` builds the index from all the mirror's rows, which stands for the full TTL with no retry window (it is complete, only stale), and logs one warning carrying `endpoint: 'company_tickers'`. Strict mirror-only (`FALLBACK_LIVE=false`) uses the mirror's rows alone (ticker→CIK, no series/class) and sends nothing; an empty ticker layer fails there as not synced. The mirror stores a fund symbol with an empty name — its schema has no series column — and `mirrorIndexRow` reads that empty name as the fund marker; a symbol claims `byTicker` in the order equity → live fund → mirror fund, so a live entry supersedes the mirror's bare row and keeps its series (#135). **The live fund slice has its own failure window:** a failed MF load (any HTTP failure, a malformed body, the block gate's refusal) is not cached for the ticker-cache TTL — `loadMfTickers` returns no rows and a `retryAt` `FUND_SLICE_RETRY_MS` (60 s) out, or the error's `retryAfter` when longer, so nothing is retried while the gate is closed. The first resolution after it refetches the file once, single-flight, onto the cached base (`reloadFundSlice`), which keeps its own load time: the equity index is not refetched and its TTL does not move. A per-resolution retry was rejected — against a persistent 5xx it adds three requests and ~3 s of backoff to every lookup (#119). Each failed load logs one warning through the framework's global `logger` (no request context to attach to) carrying `endpoint: 'company_tickers_mf'` (never the URL — framework log guidance keeps URLs out of log fields), the HTTP status or `malformed_body`, and the retry delay (#43). - **Name and ticker resolution:** `resolveCik` walks numeric CIK → the listed-symbol lookup (`listedSymbol`), for 1–5 letters in any case and for anything typed in uppercase; its answer never changes, but a symbol an earlier, different company held carries a `reading` naming that holder (#237) → for input typed in uppercase, the ticker-spelling readings (`readTickerSpelling`) → the name passes (exact → exact once folded → exact once initials join → prefix → substring over `allEntries`), weighed for input typed in uppercase against a former ticker (`readFormerTicker`, below) → only when no tier matches and the query has two or more words, the word-order tier → for input not typed in uppercase, the listed-symbol lookup the first step skipped, then a former ticker. An uppercase former ticker answers ahead of the names unless a current name is the input exactly (`CHUBB` stays Chubb Ltd, not CommerceHub), or its holder is listed no more and the names find one company (`DINE` stays Dine Brands, not Rewards Network): it reads `BK` as BNY over BK Technologies Corp, whose name only starts with it, and settles `STR`, which starts many names. Lowercase input keeps the name passes first (`bk` keeps its name-pass answer; `amrk` reaches Gold.com). **The word-order tier (#236)** compares a query's words as a set: the key is the joined form (initials joined) with its terminal suffix normalized, words sorted, and a current or former name meets it under the same key, or under its key with the terminal suffix dropped, so `Charles Schwab` names SCHWAB CHARLES CORP and `JB Hunt Transport Services` HUNT J B TRANSPORT SERVICES INC. Names of one word never reach it, the query's own suffix is never dropped, current names list before former ones, and several CIKs list as the ambiguity. It runs only when every name tier misses, so it changes no answer that resolves without it, and it lives in `resolveCik` because EDGAR's entity index (below) answers `Charles Schwab` with a 2008–2010 Reg D filer's exact hit, and index hits come after every local tier. **`company_search` alone then tries the network** when `resolveCik` returns no match: EDGAR's entity index (`resolveEntityByName`, `keysTyped=`, SEC's first 10 hits), then one quoted full-text search of 10-K subsidiaries exhibits (`findSubsidiaryParents`). SEC's trailing ticker list is split from each index name (`SCHWAB CHARLES CORP (SCHW, SCHW-PJ, SCHW-PD)` → name `SCHWAB CHARLES CORP`, ticker `SCHW`), and the hits are ranked on name tiers 0–4: one CIK at an exact tier (0–2) resolves, with a `resolvedAs` reading; several at the best tier fail `ambiguous_company`, with SEC's `hits.total` as `total_matches` when SEC returned only part of its hits (#230); a lone hit at the prefix or substring tier is a near match (Near-match suggestions). Only when no index hit reaches a tier does the exhibits search run: the filers of an EX-21 hit go in the `no_match` message and `data.parents`, ticker-file filers first, then the newest exhibit, and any other document's hit is never named, since a lender's schedule of investments or a competitor's narrative says nothing about ownership; with no EX-21 hit the message points to `secedgar_search_filings` with the quoted name, its JSON arguments passed as an error `literals` value so `content[]` prints them as code, unescaped. A non-US listing code (`7203.T`), a query with no letter or digit, and any query a local pass answers send no request, and strict mirror mode runs both steps, since EFTS is not mirrored. **An index failure fails the call with its reason** (`efts_degraded_response`, `sec_unavailable`, `rate_limited`), never `no_match`, because the server then cannot say whether the company exists; **an exhibits-search failure keeps `no_match`** (SEC unavailable or a rate-limit refusal) and says that search did not run, since the answer is already `no_match` and no request follows it. The caller's cancel rethrows from either step. Four normalizations hang off the name passes. The name passes' **exact** tiers also compare suffix-normalized forms (`normalizeCompanySuffix`): the terminal whitespace-delimited token only, a trailing `.`/`,` stripped as part of recognizing it, expanded to one of four canonical long forms — `corp`→`corporation`, `inc`→`incorporated`, `co`→`company`, `ltd`→`limited`. **The four buckets are never merged.** The registry carries distinct registrants differing only in which suffix they use (`TORO CO` 0000737758 vs `TORO CORP.` 0001941131, and the same shape on Blue Owl and Fluent), so a single "has a corporate suffix" marker would compare two companies equal. Only the terminal token is rewritten — the same words sit mid-name in titles ending in a different suffix (`American Water Works Company, Inc.`) — and a terminal token that merely contains one (`Ltd./ADR`) is not a suffix. Punctuation elsewhere in a name is the fold's (below). `llc`/`plc` and the `lp`/limited-partnership family get no bucket and must never fold into the `ltd` bucket (#107); they drop for a bare query (below) but never normalize into one of the four. The name passes **resolve within the best tier a current name reaches**: several CIKs there are the ambiguity, listed without the tiers below, so `Itron` resolves to ITRON, INC. instead of listing SOLITRON and POSITRON beside it. The exact tier also matches a registry name minus its terminal suffix and a comma before it (`stripCompanySuffix` over `DROPPABLE_SUFFIXES`: the four buckets plus `plc`, `llc`, `lp`, `nv`, `sa`, `ag`, and `se`, with or without periods), so `Apple` names `Apple Inc.` exactly. The wider set is deliberate: with the four buckets alone, `Rio Tinto` silently picked RIO TINTO LTD over RIO TINTO PLC; now both drop to an exact hit and tie. Only the registry side is stripped, so `Toro Co` never meets `TORO CORP.` and `Rio Tinto plc` never meets RIO TINTO LTD, and two suffix variants of one base name (`Blue Owl Capital`) tie. Current names (registry rows, which carry a ticker) choose the tier. A former name joins at that tier or better but never outranks one — otherwise `Travelers` resolves to Citigroup through its former name `travelers inc` — and decides alone only when no current name matches (`Facebook Inc`). Against the 2026-10-02 registry plus the former-names asset, 930 of 34,865 name queries move from ambiguous to resolved and no resolved CIK changes (#155). The **punctuation fold** (`foldNamePunctuation`, #210) deletes `,` `.` `'` `’` from the query and from every current and former name, then collapses each whitespace run to one space (#219), so `QXO Inc` meets `QXO, Inc.`, `Fluent Inc` meets `Fluent, Inc.`, `Jeffs Brands` the former name `jeffs' brands ltd`, and `Forgent Networks Inc` the former name `forgent networks inc`; a period is deleted, never spaced, so `U.S. Bancorp` meets `US BANCORP \DE\`. It is comparison-only — returned names and `suggestCompanies` never see it — and a helper of its own: `normalizeCompanySuffix` and `stripCompanySuffix` read the as-written strings, and the folded tiers run them again on the folded forms. Without the fold a weaker tier answered a punctuation-free spelling with another registrant (`BlackRock Inc` reached BLACKROCK INCOME TRUST, INC. by prefix, `Block Inc` H&R BLOCK INC by substring). Against the 2026-11-03 registry plus the former-names asset, no title queried as listed changes its answer and no title or former name changes CIK; six former names typed as listed now match several companies, the current registrant listed first, since a current title now matches them too (`energizer holdings inc` lists ENERGIZER HOLDINGS, INC. ahead of the former holder). The **initials join** (`joinSpacedInitials`, #219) turns each run of two or more one-letter words in a folded form into one word (`j m smucker co` → `jm smucker co`), so `JM Smucker` and `J.M. Smucker` meet `J M SMUCKER Co` and `WP Carey` meets `W. P. Carey Inc.`; it is comparison-only like the fold, and its tier runs the suffix helpers again on the joined forms. **No hit starts or ends inside a word** (#218): a prefix must end at a word boundary of the joined name and a substring must start at one in the name as written — a boundary is regex `\b`, a letter or digit on exactly one side — so `SY Bancorp, Inc.` no longer names Embassy Bancorp through `embassy`, `helix acquisition corp ii` no longer lists the later `Helix Acquisition Corp. III`, and `ticker:MRO` (a delisted symbol) no longer scopes to PermRock Royalty Trust. Tier order: exact as written, exact once folded, exact once initials join, prefix of the joined form, substring as written. Each tier ranks above the looser one after it, so a title typed as listed never ties a former name equal to it only once folded (`McGraw Hill, Inc.` stays alone beside S&P Global's former `mcgraw hill inc`), and `US Gold` names U.S. GOLD CORP. alone instead of tying McEwen's former `u s gold corp`, equal only once joined. Substring stays as written, so a query ending mid-word still reaches a name containing it as written (`Berkshire Hath`) but never one containing it only once folded: `Helix Acquisition Corp. III` folds to a name holding `helix acquisition corp ii`. A query with no letter or digit (`.`, `'`) is `no_match` instead of folding to a string that starts every name. Against the 2026-11-03 registry plus the former-names asset (#218, #219), no title queried as listed changes its answer; of the former names typed as listed, nine that matched several companies now resolve to their own CIK (`ace ltd` no longer lists MDA Space Ltd. beside Chubb), `total` keeps its CIK in a different list, and `u s gold corp` now matches several, U.S. GOLD CORP. first; 174 of the 178 joined or dotted spellings of the 63 names that space their initials resolve to their own CIK, against 15 before, the other four naming U.S. GOLD CORP.; and all 37 double-spaced former names resolve typed single-spaced, against 17 before. Only a query with no letter or digit loses its match, but a query cut short mid-word that the prefix tier answered alone can now list several companies (`baldw` lists Baldwin Insurance Group beside Matson, whose former name is `alexander & baldwin inc`). `\DE\`-style state tags, `&` `-` `/`, and a dotted word SEC splits in two (`AMAZON COM INC` for `Amazon.com`) stay out of scope. The **listed-symbol lookup** retries a dotted symbol with `.`→`-` on a miss — brokers and market data write `BRK.B` where `company_tickers.json` lists `BRK-B`, and that file carries exactly one dotted ticker, `ALDF.U` (a unit of Aldel Financial II, CIK 2031561), with no `ALDF-U` beside it, so the substitution cannot land on the wrong registrant (#110). It answers ahead of the name passes for 1–5 letters in any case and for anything typed in uppercase, so neither a reading nor the period fold ever takes a symbol the file lists; a symbol typed in mixed or lower case outside that shape (`brk.b`) meets it after the name passes, as before. **The ticker-spelling readings** (`listing-codes.ts`, #235) take input typed in uppercase that the lookup missed, before the name passes, whose period fold read `GS.K` as GSK plc, `SU.N` as SUN, and `CF.N` as CFN Enterprises: a US venue code after a listed symbol (`N` `O` `OQ` `A` `P` `K` `PK`; `UEC.N` → UEC, `BIP.PA.N` → BIP-PA); a preferred class run together, `P` or `PR` with a base of two or more letters, read as the listed `-P` (`CFGPH` → CFG-PH, `PCGPRE` → PCG-PE; one-letter bases such as `C-PN` are left out, since `TPC` is Tutor Perini); and a root with no listing of its own, read as the company its hyphenated classes belong to (`GLOP` → GasLog Partners LP), or `ambiguous_company` when they belong to several. A non-US exchange code (`ME` `L` `HK` `T` `TO`: `7203.T`) is no match, and each tool's not-found message appends `nonUsListingNote`'s sentence saying EDGAR indexes SEC registrants by US ticker, name, or CIK. A match a reading produced carries `CikMatch.reading` (`UEC.N read as UEC (URANIUM ENERGY CORP)`; a root names its classes), which `search_filings` puts in `effectiveQuery`'s scope parenthetical (`(entity scope: CIK 0001334933, UEC.N read as UEC (URANIUM ENERGY CORP))`), `compare_companies` in one `caveats` line per input, and the other nine tools in a `resolvedAs` enrichment field, never `notice`, which each writes later and which is last-wins. Uppercase only, so `Ang` and `oak` keep their name-pass candidate lists; the accepted cost is a two-letter dotted initialism typed in uppercase without its closing period reading as a venue code — `B.P` as B (Barrick), `H.P` as H (Hyatt), `W.P` as W (Wayfair), `J.P` as J (Jacobs) — while `B.P.`, `H.P.`, and `b.p` still reach BP and HP. `search_filings`' `ticker:` token gets all of it through `resolveCik` with no change of its own. **No consumer reads the first of several matches** (#223): `company_search`, `get_material_events`, `get_financials`, `get_snapshot`, `get_insider_transactions`, and `search_filings`' `ticker:` token throw `ambiguous_company` (`ValidationError`, one reason and one wording across the six, #228; `data.matches` of `cik`/`name`/`ticker`, the first 10, also listed in the message, and `data.total_matches`, the full count) before any SEC read; `find_holders` and `get_beneficial_owners` `ambiguous_issuer`, `get_institutional_holdings` `ambiguous_entity`, `get_fund_holdings` `ambiguous_fund`, and `compare_companies` reports a per-company `failed[]` reason `ambiguous`. `search_filings`' `cik:` token resolves a zero-padded CIK, which never matches several. `resolveCik` returns every candidate of its best tier, uncut, and `ambiguousMatches` (`src/mcp-server/tools/ambiguous-matches.ts`) is the one cut every ambiguity error shares: the first 10 in resolution order, `data.total_matches` beside them, and, when the list is partial, a message ending `Showing 10 of 66 matches; for one not listed, retry with a fuller name, its ticker, or its CIK.` — `retry with its ticker or CIK.` for `search_filings`' `ticker:` token, which takes no spaces and so no fuller name. Its consumers are the six `ambiguous_company` tools, the four role-specific tools (each keeping its own reason, item wording, and hint), and `compare_companies`' ambiguous `failed_companies[].message`, which takes the list and the sentence and no field. `get_institutional_holdings`' fallback for a name the registry misses, EDGAR entity search (`resolveEntityByName`, `keysTyped=`), returns SEC's first 10 hits beside SEC's `hits.total`, which becomes `data.total_matches` — `of at least 10,000`, with `data.total_matches_is_lower_bound: true`, when its `relation` is `gte`, a floor (#230). When no `compare_companies` input resolves, `no_companies_resolved`'s message repeats each input's `failed_companies[].message`, so `content[]` names the same reasons and candidates as `data.failed_companies` (#231). The 5-candidate cut it replaces hid 61 of `American`'s 66 candidates, American Airlines among them, behind a list that read as complete (#229). - **Former-name resolution:** `src/services/edgar/data/former-names.json` is a committed asset (generated offline via `bun run gen:former-names`) containing `[lowercasedFormerName, zeroPaddedCIK]` tuples. `buildTickerCache` folds them into `allEntries` for name search and the trigram pass. Regenerate on demand after major M&A waves — former names are immutable, so the file rarely needs updating. - **Former-ticker resolution (#237):** `src/services/edgar/data/former-tickers.json` is a committed asset (generated offline via `bun run gen:former-tickers`) of `[TICKER, zeroPaddedCIK, "YYYY-MM", title]` tuples: every symbol → CIK pair a monthly Wayback Machine capture of `company_tickers.json` lists and the live file at generation does not. The month is the latest capture listing the pair — a **capture date, not a delisting date** — and the title is the holder's in that capture. The generator reads Wayback's CDX index once, then each capture sequentially and paced, decodes gzip-encoded bodies, and skips one that does not parse; a capture repeating any digest but the newest one seen so far is a **stale copy** SEC's CDN served again (2019-08 repeats 2017-08's file byte for byte), so it adds no month. Its downloads are cached, so a rerun after a stop resumes. `buildTickerCache` indexes the rows beside `allEntries` (`buildFormerTickerIndex`), and the lookup sends no SEC request, strict mirror mode included. A symbol several companies held resolves to the **most recent holder** rather than failing as ambiguous, so no call that resolves today starts failing; its `reading` names that company (its current name and ticker when listed, else its title as last listed), the capture month, and any earlier holders. A symbol the file still lists answers as listed, and when an earlier, different company held it the `reading` names that holder, its CIK, and the month (`XOM` stays ExxonMobil Holdings Corp and names EXXON MOBIL CORP). A symbol that leaves the file after the asset was generated misses until the next `bun run gen:former-tickers`; run it on demand, as with former names. - **Near-match suggestions:** `suggestCompanies(query, allEntries)` runs a Dice-coefficient trigram pass on the zero-hit search path, returning up to 3 candidates above a threshold. Each entry is scored on its **name and its ticker**, keeping the higher of the two — one ranked, deduped, top-N list rather than two disjoint ones, so a strong ticker match can outrank a weak name match and vice versa, and a ticker-shaped miss gets help instead of a bare `no_match` (#111). An entry missing either field is simply not scored on it: former-name entries carry a name and no ticker, and MF rows carry neither a name nor a place in `allEntries`, so fund tickers stay structurally unreachable here regardless of what the scorer does. Suggestions appear in the `no_match` error `data.suggestions` and in the error message. Never auto-resolves. A lone EDGAR entity-index hit that reaches only the prefix or substring name tier leads `data.suggestions`, ahead of the trigram candidates (#236). Only the throw that attaches suggestions or parents names the field in its hint (`company_search` passes its own `recovery.hint`: one sentence per field attached, then the contract text); the contract's `no_match` recovery never mentions it, since a zero-hit miss with no near match and the bare-CIK 404 carry no `suggestions` (#153). - **XBRL friendly names:** `concept-map.ts` maps `"revenue"` → real XBRL tags; handles historical tag changes (ASC 606), including both assessed-tax variants — a filer presenting revenue gross of excise/sales tax reports only `RevenueFromContractWithCustomerIncludingAssessedTax`, and without it the walk fell through to tags SEC retired in 2018 (#98). The Including variant sits BEHIND `Revenues`, not ahead of it — the two disagree for filers that tag gross sales and net-of-excise sales under separate elements, so promoting it over `Revenues` swaps the definition partway back through the history and prints a step change that is a tag switch, not a business fact; behind `Revenues` it only fills frames no current tag covers. The same precedent places successor tags: `capex` walks `PaymentsToAcquireProductiveAssets` behind the PP&E-only tag (it also counts software and intangibles) and `interest_expense` walks `InterestExpenseNonoperating` behind both older tags, so NVIDIA, Amazon, Visa, and Microsoft reach current values and no frame the older tags cover changes (#125); the banks' `InterestExpenseOperating` (JPMorgan, Bank of America, and Wells Fargo report interest expense only under it from 2024) goes behind that, since it matches `InterestExpense` for 33 of 37 CY2025Q2 filers reporting both but `InterestExpenseNonoperating` for 0 of 4 (#147). SEC serves both successors with no label, so the line falls back to the concept's label. A different-definition tag still goes in `relatedTags`, never `tags` — `ppe_net`'s finance-lease-inclusive total is the primary line for Alphabet, Meta, and Tesla (#36, #130). `ifrsTags` covers every concept with an IFRS element confirmed present in a live 20-F filer's companyfacts; `notes_payable` is deliberately unmapped (IFRS presents borrowings as one caption, with no notes/debt split) and `shares_outstanding` is `dei`. Never add an `ifrsTags` entry from the taxonomy alone — an element existing is not evidence filers tag it (#99). `ifrsTags` order is semantic the same way `tags` is: index 0 is the counterpart of the us-gaap index-0 tag, so the same concept answers the same question under either taxonomy (`accounts_receivable` leads with the trade-only caption, not the combined trade-and-other one; `share_repurchases` with the financing outflow, not the treasury-only line a share-cancelling filer tags zero). Verify a candidate order against filers that report BOTH tags — presence alone does not settle which one leads. One concept has no right order: IFRS filers split between the employee-scheme share-based-payment element and the IFRS 2.51(a) expense total, and the two invert between filers (SAP's total is its real line with the employee element a two-period fringe; Sanofi is the exact opposite), so `stock_based_compensation` carries `ifrsTagSelection: 'coverage'` and picks per filer by which tag covers more standard periods (#101). It is the only concept that does, and the flag is scoped to `ifrsTags` on purpose — its us-gaap pair IS a ladder, since `AllocatedShareBasedCompensationExpense` is the disaggregated line and out-covers the total for Alphabet, IBM, Tesla, and P&G. - **`fetch_frames` reads one tag:** a friendly name sends one frames request, for its first tag; the rest come back in `unqueried_tags` on a success and on `no_data`, whose hint says to query each. Revenue's first tag has no annual frame before CY2014, so an older revenue screen names one of the listed tags directly. The hint names no start year: frame coverage is per tag (#207). - **`compare_companies` lists:** `companies` takes the first 10 inputs as given and `concepts` the first 8 distinct known concepts (merged spellings count once, unknown names none); the rest come back in `deferred_companies` / `deferred_concepts` behind `EDGAR_CLAMP_OVERSIZED_LIMITS`, through `batchClamp` in `limit-clamp.ts`, and the 10 x 8 bound keeps the inline-matrix cell ceiling valid (#201). The notice names the re-call — three when both lists defer: deferred companies with the 8 compared concepts, the 10 companies read with deferred concepts, and the two deferred lists together — since a re-call pairing one deferred list with the other full input list passes the other cap and repeats cells another re-call reads; a lone deferred company is paired with one of the 10 in its two re-calls, and that company leaves the second. An input resolving to a CIK an earlier input already holds is read once, under the earlier input, with a caveat like the concept merge's; it still takes a slot (#217). - **Unknown concept names:** `findUnknownConcept` in `concept-map.ts` runs in `get_financials`, `compare_companies`, and `fetch_frames` before company resolution or any SEC request. After trimming, a catalog name or alias in any case or separator form passes, as does a lowerCamelCase spelling of one, and so does anything matching `/^[A-Z][A-Za-z0-9]*$/` — every item element in us-gaap, ifrs-full, dei, and srt has that shape and SEC matches tags case-sensitively, so no input that could return data is rejected, and a well-formed tag that 404s stays `no_data` / `no_concept_data` (#45). Everything else fails as `unknown_concept` (`NotFound`) and never reaches a request URL, where the tag is interpolated as a path segment. The hint is a formula from the `DERIVATIONS` table (`free_cash_flow`, `ebitda`, `working_capital`; read before the tag-shape test, since no taxonomy element is named `EBITDA`) or up to three catalog names by Dice trigram score ≥ 0.3 against name and label, ties by name — the label is the only route from `capital_spending` to `capex`. `total_debt` deliberately has no formula: `debt` is long-term only. **Aliases (#240):** a catalog entry's `aliases` are its label keyed like a name (`capital_expenditures`, `cash_and_cash_equivalents`; `&` as `and`, so `research_and_development_expense`) or that label minus its sign qualifier (`income_tax_expense`, `pre_tax_income`), 18 in all, and only spellings with one reading: `total_debt`, `total_equity` (the noncontrolling-interest total is `equity`'s related tag), `eps`, `cost_of_revenue`, `cost_of_sales`, `net_sales`, `ppe`, and `diluted_shares` each name more than one quantity and stay unknown. Names and aliases resolve through one `Map` built at load, which throws on a key two entries claim, and `DERIVATIONS` is a `Map` too, so `__proto__` and `constructor` read nothing off `Object.prototype` and fail as `unknown_concept` rather than passing as a concept with no tags; `Constructor` is tag-shaped and stays a raw tag. An input opening lowercase that misses as typed is split at its camelCase humps and looked up again in both tables (`operatingIncome` → `operating_income`, `freeCashFlow` → its formula): as typed first, so `capEx` stays `capex`, and never for an uppercase-initial input, since no item element opens lowercase while a split `InterestExpense` would read the four-tag `interest_expense` ladder and `SharesOutstanding` a `dei` tag. Suggestions score the split form; aliases are not scored, so no snake_case input's suggestions moved. `secedgar_search_concepts` matches an alias (four spell out a label's `&`, so no label substring holds them), but neither it nor `secedgar://concepts` lists aliases. `compare_companies` fails only when every concept is unknown; otherwise it answers the rest and lists each unknown once in `unknown_concepts`, never as a per-company gap (#128). Known concepts are deduped the same way, by what they resolve to — the catalog entry, or the trimmed raw tag — so `revenue`, `Revenue`, and ` revenue` are compared once under the first spelling, as are `capex`, `capital_expenditures`, and `capitalExpenditures`, and a caveat names the merged inputs; a catalog name and a raw tag it walks (`revenue` and `Revenues`) are different concepts and stay separate (#145). `trigramScorer` lives in the `trigram-similarity.ts` leaf so the catalog scores names without importing the HTTP service. - **Taxonomy retry on `no_concept_data`:** when no tag answers under the requested taxonomy, `get_financials` reads companyfacts once (the #13 probe) and leads the hint with the retry that payload supports — `Retry with taxonomy: "ifrs-full" — eps_diluted resolves there to DilutedEarningsLossPerShare.` for a friendly name (resolved under each other accepted taxonomy through `resolveConceptTarget`, first tag in ladder order carrying a framed value), `DilutedEarningsLossPerShare is reported under ifrs-full — retry with taxonomy: "ifrs-full".` for a raw tag. It names only `us-gaap`, `ifrs-full`, or `dei` (companyfacts also carries `srt`, `invest`, `ffd`), fires in every direction whether or not the requested namespace is present (Toyota reports both and `cogs` only under us-gaap), and the namespace summary follows, or stands alone when no retry applies. It never answers from the other taxonomy itself: `taxonomy` defaults to `us-gaap`, so an explicit choice cannot be told from an omitted one (#164). - **Series staleness:** two causes, one symptom, one caveat — `seriesStalenessCaveats(tag, label, newest, reference)` in `concept-series.ts` owns both, so a series is never flagged twice in different words. A friendly name's tag array is walked in order, so a tag SEC retired can win when every current tag reports nothing for the filer; SEC ships that tell in the taxonomy `label` (`"… (Deprecated 2018-01-31)"`), already in hand — no extra upstream call. Generalized rather than pinned to one pair: `revenue` and `cogs` both carry retired fallbacks (#98). A *current* tag whose series simply ends carries no tell at all, so the fallback signal is the gap itself: **730 days**, two full fiscal years behind the reference. The floor has to clear one year plus a filing window — an annual-only concept sits a fiscal year back until the next report lands, and a 20-F is due four months after year end — which puts a healthy filer around 500 days. Each tool passes the freshest reference it has without an extra call: `get_snapshot` and `compare_companies` pass `newestReportedPeriod(facts, taxonomy)`, the newest standard period the filer reports across the whole concept catalog — it isolates a line lagging its siblings and collapses to silence for a filer that stopped filing, whose every line is equally old, and it deliberately reads the whole catalog rather than the requested concepts, or a single-concept comparison would measure a series against itself. The reference skips the `dei` namespace — cover-page facts track the filing, not the statements, so they stay current for a registrant that stopped reporting under this taxonomy; counting them put Toyota's us-gaap profile (last reported fiscal 2020) six years behind a cover-page date and flagged all 28 of its lines at once. `get_financials` passes today, because one companyconcept payload holds no filer-wide period. All three measure the *full deduped set*, not the period-filtered slice — an annual view trailing a live quarterly series is a property of the filter. The retirement stamp wins when both apply; it names the concrete cause. `get_financials` therefore reads the clock, so its tests pin one (`vi.useFakeTimers({ toFake: ['Date'] })`) (#102). - **XBRL deduplication:** Filter to entries with `frame` field to get one value per standard calendar period. SEC frames the latest-filed fact for a period, and since the pay-versus-performance rule a DEF 14A re-tags five years of annual `NetIncomeLoss`, so the proxy's figure — often rounded, sometimes mis-scaled or sign-flipped (TheRealReal's CY2024 at +134.2B against the 10-K's −134.2M) — holds the frame. A frame held by any Schedule 14A/14C form (`isProxyForm`) is answered with the latest-filed fact from another form with the same tag, unit key, `start`, and `end` (`reportingFormTwin`), keeping the frame; with no such fact the proxy value stands. Only proxy holders are replaced — a periodic-form allow-list would revert Bank of America's CY2013, framed on a 2016 8-K recast (10,539M) that no later 10-K repeats. The swap runs per tag and per unit key BEFORE cross-tag priority, so tag 0's proxy-held frame takes its twin from tag 0 and is never handed to a lower tag, and it costs two regex tests per framed value when no holder needs correcting. SEC also frames any year-long duration as `CY####`, including the trailing-twelve-month figure a 10-Q discloses, so a later 10-Q can hold an annual frame for a year the filer has not closed (Amazon's CY2026 `NetIncomeLoss` is 135.3B for July 2025–June 2026). An annual frame held by a 10-Q/10-QT takes the latest same-period fact from a form that is neither a quarterly report nor a proxy (the 10-K, in practice), else stays only when it covers a closed fiscal year a later 10-Q happens to disclose — ended before filing, and ending within a week of a fiscal-year end — else drops out of the series (#142). The fiscal-year ends come from a year-long fact from any other form in the same tag and unit key; when there is none, from the filer's own (`fiscalYearEnds`, #148): for each 10-K, 10-KT, 20-F, or 40-F accession (amendments included), the latest end among its year-long facts that ended before filing, across every tag and unit. Every year-long annual-report fact would admit off-cycle quarter ends (Merck's and P&G's 09-30, Toyota's 06-30) and keep a trailing figure ending there. The frame stays untested only when the filer has no such accession. Amazon's April–March `EffectOfExchangeRate…` CY2020/CY2021 and July–June `OtherAdjustmentsForNoncashItems…` CY2008/CY2009, each held by one 10-Q, drop; Deere's November–October `RestructuringAndRelatedCostIncurredCost` years stay. Leaving every twinless 10-Q holder out would delete real years: across seven filers' companyfacts, 9 of 74 such holders are closed fiscal years reported only in a later 10-Q (Merck's `AccountsReceivableSale` CY2020). `frameHolderFact` owns both holder rules; its `FiscalYearEndsSource` is called only for a holder its own series cannot test, so each path pays for the filer's ends only then: `seriesFromCompanyFacts` scans the payload in hand once (`filerFiscalYearEnds`), `get_financials` reads companyfacts only when a first resolution meets such a holder (reusing a served-empty read) and treats that read as best-effort — the first pass is already a complete series, so a failed read keeps the frame untested and a caveat names the 10-Q-held years shown, while a cancelled call still fails — and the mirror's `getFrames` reads that filer's rows through the `cik` index within a 16 MiB per-call budget (`FISCAL_YEAR_END_READ_BUDGET`; past it, rows keep the untested frame). The mirror's `getFrames` applies the holder rules and marks its response `holderFormsResolved`; the live frames API carries no form, so `fetch_frames` caveats annual `NetIncomeLoss` frames (#123) and annual frames for a calendar year still inside its 10-K window, through April of the next year (#142). When a friendly name maps to multiple tags, same-frame collisions resolve by **tag priority** — the lower-index tag wins (index 0 = preferred total, e.g. IFRS `Revenue` over the `RevenueFromContractsWithCustomers` sub-line), ties within one tag by latest `filed` (restatement). Tag-array order in `concept-map.ts` is therefore semantic (#44). **Frames compete only within one unit key, and a series reads one unit** (#146): a 20-F filer frames a year in its reporting currency and in a USD convenience translation, and letting every unit compete swapped the unit wherever the other unit's fact was filed later — Harmony's revenue read ZAR except a 1,264M USD CY2016, with nothing on the row to say so. `resolveFrameSeriesByUnit` runs the holder rules and tag priority inside each unit key (the `coverage` winner is still picked across all units), and `rankSeriesUnits` orders the units: newest value's end, then more framed periods, then alphabetical. Newest-first follows a presentation-currency change (Prudential plc's GBP runs to 2018, its USD from 2017); the period count settles the convenience-translation tie (Alibaba's CNY over USD on the same newest year). Never the catalog mapping's `unit` — it reads `USD` under `ifrs-full` too, and keying on it handed 169 of 193 multi-unit catalog tags in a 33-filer sample to their translation. `get_financials` names each other unit with its period count and span in a caveat and reads one through its `unit` input (an SEC unit key; the `-per-` spelling and a unique case-insensitive match are accepted; an unreported key fails as `no_unit_data`, listing the keys the concept reports), and its staleness caveat measures the newest value across units, so a translation the filer stopped publishing is not reported as a stopped series; once every unit has stopped, the caveat describes the series shown, by its own end. `get_snapshot` and `compare_companies` read the default unit with no unit caveat — it would fire on 25-plus lines for every convenience-translation filer — so `compare_companies`' units caveat fires only across companies. Unit keys are never normalized: Costco's 2008 and FedEx's 2007 EPS, mistagged `pure` / `shares`, leave their series and are named in the caveat. `fetch_frames` is unaffected — the frames API and the mirror's `getFrames` are unit-scoped already. The rules live once in `concept-series.ts` (`resolveFrameSeriesByUnit` / `resolveFrameSeries` over `CompanyConceptUnit`s carrying a `tagIndex`, their source `tag`, and their unit key; `seriesFromCompanyFacts` for the whole-company reads) — every tool reading XBRL facts goes through it so their numbers agree. `resolveConceptTarget` in `concept-map.ts` is the matching taxonomy+tag resolution (mapping taxonomy wins over the `us-gaap` default; `ifrs-full` uses `ifrsTags` when present) and carries the `tagSelection` for the array it picked. `fetch_frames` resolves through it too, so its `taxonomy` input — `us-gaap` or `dei`, the only namespaces SEC publishes frames for — picks the namespace a raw tag is read from, a catalog name keeps its own mapped taxonomy under the default, and an explicit `dei` reads a catalog name's tags from `dei` exactly as `get_financials` does (#143). **`priority` is the default and coverage ranking is never the general rule** — it answers "which element does this filer maintain", which is the question only when the elements mean the same thing. Where they mean different things the array order is the answer and period count is noise: Molson Coors keeps eleven calendar years under a 2018-retired revenue element and ten under the assessed-tax-inclusive one against five under `Revenues`, so ranking `revenue` by coverage hands it either the dead tag or its gross-of-excise line in place of the continuous net series; Smucker and Spotify survive it only on a coincidental tie in period count (#101). `preferredTagIndex` picks the winning tag under either rule. Every resolved value names its own source tag — `get_financials` rows, `get_snapshot` points, and `compare_companies` cells each carry one — and the line-level `tag`/`label`/`unit` follow the tag behind the newest value (`describingTag`): under `coverage` that is the winner, and under `priority` a successor appended behind an older leader answers the recent frames, so describing the line by the leader would name a tag that produced none of its current values (#125). A companyconcept or companyfacts unit served as anything but an array is dropped at the service edge (SEC serves Visa's and Coca-Cola's `NetIncomeLoss` companyconcept as `"units":{"USD":{}}`); a companyconcept payload left with no units reads as served-empty, and `get_financials` answers every such tag from one companyfacts read, which it reuses for the no-data probe (#141). The two rules also differ in what the losers contribute: under `priority` the array is a ladder, so a lower tag fills frames the leader does not report, while under `coverage` the tags are alternates and only the winner contributes — gap-filling from an alternate splices two definitions into one series, and the filers reporting both tags disagree where they overlap (Ferrari tags CY2022 at EUR 16.2M under the employee element and EUR 20.9M under the IFRS 2.51(a) total), so the filled series prints a step that is a tag switch rather than a business fact. Seven percent of the 224 IFRS filers reporting either `stock_based_compensation` tag have that shape; a winner-only series that then ends years back is what the staleness caveat is for. - **Fiscal-period caveats:** SEC reports a filer's fiscal Q4 as the 10-K residual, never as a discrete quarterly fact, so the calendar quarter that a filer's fiscal Q4 *spans* carries no frame-tagged quarterly value. That is not always the quarter holding the fiscal year-end date — a January year-end closes a fiscal Q4 running Nov–Jan, which SEC frames as calendar Q4 — and calendar-year filers are affected too (no discrete Q4). `fiscal-periods.ts` owns both directions: `fiscalQ4Caveats(period)` for the cross-company frame view (`fetch_frames`), `missingQuarterCaveats(frames)` for the per-filer series view (`get_financials`, `get_snapshot`, `compare_companies`) (#95). The per-filer detection is data-driven — no maintained filer list — and reads only the newest 4 years carrying quarterly frames, applying the per-year quorum inside that window rather than before it, because SEC frame-tagged all four calendar quarters before ~CY2021 and drops the fiscal-Q4 quarter after; a whole-history read would let the older years mask the live gap, and filtering first would let a filer whose recent years are too sparse silently describe the older regime. **Keep that slice-then-filter order.** The detector reports a *set* of absent quarters, not one: a filer whose remaining fiscal quarters also span non-calendar durations loses two (Costco tags only calendar Q1 and Q4), and the per-year quorum is two of four — low enough to admit that shape, high enough to bound the absent set at two and keep a one-quarter stub year from dragging a third in (#100). Message wording and the derivation advice differ between the one- and two-quarter cases; the three consuming tools all iterate `caveats`, so nothing downstream assumes at most one entry. - **`get_financials` period default:** with `period_type` unset the series defaults to `annual` (clean FY series); if the annual filter empties a non-empty series whose frames are all instant (`CY####Q#I` — balance-sheet, shares-outstanding, raw instant tags), it falls back to the full series so the first call returns data (#48). An explicit `period_type` is honored. `annual` on an instant series returns the framed instants whose `end` is one of the filer's fiscal-year ends (`filerFiscalYearEndsWithComparatives` — each annual report's own year, the #148 rule, plus the comparative years it restates, the same set `compare_companies` aligns on, so MSFT reaches its first XBRL 10-K's `CY2009Q2I` and `CY2008Q2I`), read from companyfacts only on that path — which otherwise fails — and reusing a payload already read. Selection keys on `end`, never on the frame holder's form or quarter: BSX's 2025-12-31 instant, framed on its Q2-2026 10-Q while the 10-K's own fact carries no frame, is kept with the 10-Q's provenance, and a June year end lands on `Q2I`. A cover-date concept (`shares_outstanding` for a 10-K filer, dated weeks after the year end) has no year-end instant, and a failed or `null` read selects nothing; both still fail `no_period_data`, with a hint naming which (#167). Any other explicit `period_type` that excludes everything still errors. - **8-K item codes:** SEC replaced the original single-integer item numbering with the current `x.xx` scheme in Release 33-8400, effective **2004-08-23**, so EDGAR's `items` field carries two disjoint vocabularies. `eight-k-items.ts` decodes by the code's **shape**, never the filing date — dotted is current, a bare integer is legacy — because filers reported pre-changeover events under the old numbers for months after the effective date, and a date-keyed table would mislabel exactly those. The two vocabularies reuse integers for unrelated events (legacy `5` is Other Events, current `5.02` is officer departures; legacy `12` is the ancestor of `2.02`), so a dotted `items` filter never matches a pre-2004 filing — `get_material_events`' zero-hit notice says so and echoes the codes actually present. The tables back three surfaces: the tool's `items` enum, the decoded per-filing rows, and the `secedgar://filing-types` resource. A code matching a shape but absent from that regime's table comes back with no label rather than a guess. - **EFTS quirks:** `dateRange=custom` must be set when using date params; the singular `entity` param is ignored — `cik:`/`ticker:` targeting passes the resolved CIK via the plural `ciks` param (server-side scope, independent of the filing's name text, so former-name filings on the same CIK are matched); `ciks` only scopes in, so one bare entity token scopes a search (a `+` before it removed with it, and one inside a quoted phrase too, the phrase read without it) while a token led by `-` or a `NOT` term, or a second entity token, fails `unsupported_entity_scope` before any request — full-text search ignores a `-ticker:` term, and a second token sent as text matches nothing (#243); `size` is ignored entirely and every request answers with 100 hits, so `find_holders` pages with `from` alone and expresses its budget in pages (5 → 500 rows). The result window is 10,000 documents, so a `from` past 9,900 is refused as a `ResponseError` body, with or without `q` and whatever the match total; `search_filings` sends `offset` as `from` only under `sort=relevance` with no entity scope on a 2001-onward range, fails a deeper `offset` there as `offset_out_of_range` before any request (`MAX_RELEVANCE_OFFSET`, which `find_holders`' continuation recipe also names), and keeps the schema's 9,999 for its other paths, which page the rows they assembled (#214). A hit is one matching **document**, not a filing: `_id` is `:`, a filing whose body and exhibits both match arrives once per document, and `hits.total.value` and the `form_filter` aggregation (keyed on `root_forms`, amendments folded in) count documents. `search_filings` collapses hits to one row per accession (first-ranked hit's fields, plus `matched_documents` in rank order) and counts `total` / `form_distribution` from those filings; no EFTS parameter yields a search's filing count (`collapse`/`aggs` are ignored), so `total` is exact only when one window held every match, else a lower bound beside `total_documents`. **A browse is the exception:** a forms- or entity-only request (no `q`) matches each filing's primary document alone (S-1 January 2025: 200 hits over two pages, 200 distinct accessions, every `file_type` the form), so `hits.total.value` is the filing count and `total` is EDGAR's count — exact when the relation is `eq`, at any offset — trusted while the window shows one document per accession, else back to the window lower bound; `dataset.truncated` still flags a window short of it, and `form_distribution` covers the filings in hand. The straddling merge keeps the window count for its 2001-onward side, since its `total` counts assembled rows. Under `sort=relevance`, `from` counts documents and the 100-hit window runs past the page, so a page lists the filings among its own `limit` documents (the window's first rows, which can number fewer than `limit`) and only a filing straddling a page edge recurs on the next page (#124). A backend failure arrives as a 200 too, with `errorType`, `errorMessage`, `trace`, and `requestId` in place of `hits`; its handling is in the rate-limit note above (#206). Caller text is escaped before it reaches full-text search: `searchFilings`' `q` and `resolveEntityByName`'s `keysTyped` both go through `escapeEftsText`, which doubles each backslash and writes each control character (category Cc) as `\uXXXX`, so a term holding either is searched as typed. The escape lives in the service, not a tool, so `find_holders`' `search_key` re-sent through `search_filings` runs the same search. `find_holders` quotes the conformed name minus one trailing qualifier — a `\` or `/`, one to six letters, an optional closing `\` or `/` (`/[\\/][A-Za-z]{1,6}[\\/]?$/`, then a trim) — since 13F information tables print the issuer without it: `"US BANCORP"` matches 2,315 documents in the 2026-Q2 filing window, the phrase with its `\DE\` qualifier 2, and `PROGRESSIVE CORP/OH/` is searched as `"PROGRESSIVE CORP"`. A slash inside a name (`CULLEN/FROST BANKERS, INC.`) and an undelimited state token (`UNIVERSAL DISPLAY CORP PA`) stay, and `resolved_issuer_name` keeps the registry title (#205). Two registrants whose titles match once that qualifier is gone (`FIRST BANCORP /PR/` and `FIRST BANCORP /NC/`; `INDEPENDENT BANK CORP /MI/` and `INDEPENDENT BANK CORP`) search one phrase and get the same holders, and keeping the qualifier is no fix (`"FIRST BANCORP /PR/"` matches 9 documents in that window, `"FIRST BANCORP"` 458, FBP's CUSIP 353), so a name search with at least one match checks the in-memory registry (`getAllEntries`, no request) for every other current registrant (a ticker-file row, never a former name) whose title, minus its own qualifier, equals the phrase case-insensitively, and names each with CIK and ticker in a sentence pointing to `cusip`. The sentence joins the tool's `notices` list, since `ctx.enrich.notice` and `ctx.enrich.truncated`'s guidance are last-wins (#224). A notice naming a `secedgar_search_filings` call as JSON — `find_holders`' first-page and continuation recipes, `company_search`'s withheld-filings recipe — puts it in a code span (`markdown().inlineCode`, fenced past any backtick run), since `content[]` renders as Markdown and outside code the quoted phrase's `\"` loses its backslash, as would a caller's form type typed into `company_search`'s `forms` (#220) - **Submissions archive walk:** the submissions feed's `filings.recent` holds the last year or 1,000 filings, whichever is more — so 26,000+ rows for JPMorgan, and a filing day is never split (Microsoft's window runs to 1,002 rows to finish its oldest day); everything older lives on `filings.files[]` archive pages of 200–360 KB each. Every tool that reads past `recent` goes through `SubmissionsArchiveWalk` (`submissions-archive.ts`): one `ARCHIVE_PAGE_SCAN_CAP` (10) for all, pages read one at a time through the service's pacer and page cache, and no page selected when the lower bound falls on or after `recent`'s oldest date — so a call answered by `recent` sends no archive request. The caller picks the order — `newest-first` (`company_search`, `search_filings`' pre-2001 arm, `get_material_events`, `get_insider_transactions`' window), or `oldest-first` forward from a date (`get_institutional_holdings`' quarter lookup, since newest-first cannot reach JPMorgan's 2020-Q4 13F-HR on page 41 of 70 under the cap) — and can stop after any page; `scannedThrough` (the oldest page start read) and `truncated` (selected pages left unread, whether the cap, an early stop, or a caller that never started the walk left them) are reported the same way everywhere. Worst case per call is the submissions document plus 10 pages plus the tool's own document reads (`get_insider_transactions`: 111). `get_material_events` without a date window walks only to fill `limit`: it enters the walk when fewer than `limit` `recent` rows pass the `items` filter — the same count as the stop test, so unrelated recent 8-Ks never stand in for matches (#139) — and stops on the page that fills it with rows passing that filter, and its `dataset.truncated` is true whenever selected pages went unread — including when `recent` alone fills `limit` and no page is read, since the dataframe then holds only the recent window (#134); a result short of `limit` with selected pages unread (only the cap ends a walk that short) gets a notice naming the pages read, the oldest date reached, and the lever — `filed_before=` alone or in a window when undated, narrowing the window with `filed_before=` when dated, since a newest-first walk reaches older filings only through an earlier upper bound — appended to the zero-match or empty-window notice when one fires, since `ctx.enrich.notice` is last-wins; an empty-window notice the cap cut short claims no 8-K only "as far back as the scan reached" and drops its widen-the-range advice (#139); `get_institutional_holdings` stops on the first page holding a 13F-HR or 13F-NT for the quarter, and runs the #86 operating-company routing only when neither `recent` nor the pages read hold any 13F-HR or 13F-NT — a notice-only manager asked for a quarter it filed nothing for gets the plain quarter miss (#117, #133); without a `quarter`, a manager whose `recent` holds 13F-NT notices and no 13F-HR gets the notice message for its newest notice rather than the #86 classification (#133). **Page selection widens both date bounds by a day:** a manifest page's `filingFrom` / `filingTo` can sit a day off its own rows in either direction (JPMorgan's page 041 is listed to 2021-02-17 but holds 42 filings dated 2021-02-18; Apple's page 001 is listed a day past its newest row), so an exact overlap test skipped the page holding a window's edge day. The slack costs at most one extra page per bound, and every caller filters rows by their own filing dates (#137) - **13F amendment composition:** `get_institutional_holdings` composes a quarter from the original 13F-HR and its same-period 13F-HR/A amendments (`composeHoldings`), ordered by `acceptanceDateTime`, then filing date, then accession — never `amendmentNo`, since filers skip numbers and two amendments can land on one day. A `RESTATEMENT` replaces everything composed so far, earlier `NEW HOLDINGS` included, so the base is the last restatement whose table reads (the original when none does) and nothing before it is read (`superseded`). A `NEW HOLDINGS` after the base appends its rows, unless its table repeats every composed row (CUSIP, class, put/call, discretion, shares, share type; value is left out because its unit changed on 2023-01-03) — a mislabeled resubmission that would double the portfolio — in which case it applies as a restatement. Each accession's values normalize by its own filing date. An amendment whose type, index, or table cannot be read, or whose table has no rows, is `not_applied` instead of blanking the quarter. The original's table is always read (it backs `total_holdings_in_filing`); each amendment table is two more requests on the call's `CallScope`, and a 429 or a spent budget stops further optional reads (#198). - **Filing metadata outside `recent`:** `get_filing` takes `form`, `filing_date`, and `period_ending` from `recent` when the accession is there, else from the filing's own SEC header — the raw `` / `` / `` in `index-headers.html`, which the tool already fetches to type the documents, and only when that page 404s (common before 2014) the sub-1 KB `.hdr.sgml`. `FILING-DATE` is the filing date, not the acceptance timestamp, and matches the submissions feed; an S-8 or Form 4 header has no `PERIOD`. `company_name` stays the current registrant name (#126), read from the feed of the CIK whose directory served the filing - **Filer lookup (#203, #215):** without `cik`, `get_filing`'s candidate CIKs are the filers EDGAR full-text search records for the accession, then the accession number's own prefix (the filer's CIK, or a filing agent's). A search SEC cannot answer (`ServiceUnavailable` — `sec_unreachable`, `sec_unavailable` for a 500, 502, 503, or 504 once its retries are spent (#233), `efts_degraded_response` — or the `Timeout` a 408 or 425 maps to) degrades to the prefix alone, so `get_filing` never surfaces `efts_degraded_response` and does not declare it; the caller's cancellation and `rate_limited` still end the call. When no candidate serves the filing — a directory listed the target and the archive answered 404, or no directory answered at all — `resolveFilingArchive` reads `-index.html` under the prefix CIK (`tryGetFilingPartyCiks`): SEC serves it there for any existing accession, an agent's prefix included even when that directory has no `index.json` (a 1999 SC 13D's agent path, a 2025 Donnelley 10-K's), and answers 404 for an accession that does not exist. Its party links (`browse-edgar?CIK=<10 digits>`: Filer, Subject, Filed by) are scanned in one forward pass (`filingPartyCiks`), and up to three CIKs not yet tried are searched the same way, #158 fallback included. A document no listing names skips the page, since every party's directory lists the same files. The page costs one request on a path that failed before and none when a candidate serves the filing; an unavailable page read degrades like the search. Misses after that name `cik` in their hints, never the document that just failed, and say when a filer lookup was unavailable. `tryGetFilingIndex` drops the entries SEC lists with no filename (a pre-2001 submission's documents, which exist only inside `.txt`), and a blank `document` reads as omitted - **Primary document (#161):** the primary `get_filing` reads when `document` is omitted, lists under `documents.primary`, and names in its error catalogs is the name SEC records (`recordedPrimaryNames`): the submissions feed's `primaryDocument` when the accession sits in `recent` (last path segment — an XML form is recorded through its stylesheet path, `xslF345X06/form4.xml`), else the document the header page types with the submission's form. Only with neither does the largest readable document stand in, and the viewer-page guard there matches `R\d+.html?`, not every name starting with `R` (`Report10K.htm`). Size alone picked an 8-K's press-release exhibit over the form and a 13F-HR's information table over its cover page. The header page and the feed are read together right after an index is found and before the body, since every outcome — the error catalogs included — names the primary from them. **An XML form reads as SEC renders it (#216):** the stylesheet path the feed records serves SEC's HTML rendering of the form under the filing's archive directory, while the raw XML's tags strip to one unlabeled line — Maplebear's SCHEDULE 13D/A (0001193125-26-442148) reads as 13,443 characters on one line raw and as 857 labeled lines rendered, Items 1 and 4–7 detected as headings. The rendering prints filer-written text (a 13D's Items, a reporting person's cover-page note) in `div.largetext`, styled `white-space: pre-wrap`, so that div takes html-to-text's `pre` formatter in `CONVERT_OPTIONS`, with a plain div's one line break around it: collapsed, Item 4 ran `as follows: On October 28` together. The 13D and 13G stylesheets carry that one pre-wrap class; the Form 4, Form 144, and Form D ones none. A selector, not a regex pre-pass: a pass matching `
` missed the cover-page notes, whose div carries a `style` attribute. With `document` omitted, `searchArchive` reads the rendering first (`renderingToRead`) and the raw file when the rendering answers 404 or SEC is unavailable for it, degrading like the #203 index-page read (`unlessSecUnavailable`), so a rate-limit refusal or the caller's cancel still ends the call. `primary_document` and `filing_url` name the path read; `documents` keeps the raw file as the primary; a caller-named raw file reads as it always has, and a caller-named stylesheet path the feed records for the filing reads the rendering. The rendering is read only while the index lists the raw file at `MAX_RENDERED_RAW_BYTES` (600,000) or less — a raw file listed with no size reads as filed: a holdings report renders at about 20 times its raw size (Vanguard 500 Index's NPORT-P 0000036405-26-000325: 500,525 bytes raw, 9,777,862 rendered, 4.2 million characters of text), and `html-to-text` silently cuts input past 16,777,216 characters, 28 times the bound, while a 13D/A renders at under 3 times (25,851 → 69,523 bytes) and a Form 4 rendering runs about 16 KB. The success path still sends one document request. Only the feed records the path, so a filing outside `recent` reads its raw file. `get_insider_transactions`, `get_beneficial_owners`, and `get_fund_holdings` parse the raw XML through `rawDocumentName` and are unaffected - **Filing content:** HTML → text via `html-to-text` library; pre-2005 filings produce noisier output. The library walks the DOM recursively, so `CONVERT_OPTIONS` caps it at `limits.maxDepth: 512` with a `[…]` ellipsis where the cut falls — without it deep markup overflowed the stack and one such candidate failed `search_filings`' whole pre-2001 text scan (#118). The bound must hold on both runtimes the server ships under: a fresh Node process overflows near 1,360 levels of nested lists (its most stack-hungry shape), Bun near 13,000; real HTML filings measure under 25 levels. Legacy SGML `` markers are replaced with a line break before parsing (`neutralizePageMarkers`) — the parser never closes them, so a text document otherwise nests one level per page and a long one would be cut at the cap; with them gone the deepest measured `.txt` sits under 100 (an EX-27 schedule's unclosed tags). Both changes leave every sampled filing's text byte-identical. `limits` deep-merges with the library defaults, so its 16,777,216-character `maxInputLength` (silent truncation, no ellipsis) still applies. **Plain-text SGML bodies keep their lines (#136):** the parser collapses every newline run to a space, so a plain `` body — a pre-2001 full submission, a 2000s ASCII primary document — lost the line structure `detectHeadings` anchors on (Apple's 1996 10-K came back as 104 lines with an empty outline). `preservePlainTextBodies` runs inside the one `convert()` call: a body is HTML when ``, ``, and EX-27's `` don't trip ``); any other body has every tag dropped, `&`/`<`/`>` escaped, and goes in a `
`. The list is broad because misreading HTML as plain wrecks it, while the reverse is only the old output. Tags are dropped rather than parsed: the parser formats ``/`
` as blocks inside the `
`, and a stray ``. The `` wrapper matches case-sensitively — EDGAR writes it uppercase, and an inline SVG `` is not a body — and the scan is one forward `indexOf` pass, since a lazy `([\s\S]*?)` regex goes quadratic on openers with no closer. Input with no plain body reaches the parser unchanged, so current HTML and 2000–2004 SGML-wrapped HTML convert byte-identically; their outlines change only where an unpunctuated Item cross-reference opens a line and drops out as wrapped prose (see Heading detection). Kept indentation grows plain extractions about 19%. **Every document of a submission holding an HTML document (#159):** the parser converts only `` elements when the input has one, so an HTML-era submission (HTML primary, plain EX-10/EX-27 exhibits) lost every plain exhibit, and the pre-2001 scan could not match exhibit text. When the input holds `` goes inside a `` of its own, so every document converts in document order, separated by the block break the parser already puts between two HTML documents — no per-document label, as multi-HTML submissions never had one. Uuencoded payloads (GRAPHIC, a PDF behind ``, whose encoded bytes can pass the HTML test) and machine-readable files by `` extension (`.xml`/`.xsd`/`.css`/`.js`/`.json` — a modern full submission's XBRL and renderer files read as plain) stay outside, dropped as before, and so does the SEC header, as it always was for these submissions. Input with no `` — a plain-only submission — converts whole, header included. Over 93 corpus files only the 10 submissions mixing HTML and plain documents change: each old text is a prefix of the new one and all 186 old outline entries keep their offsets. The primary is document 1, so its headings come first and exhibit headings only fill what the 50-entry cap leaves; cross-document keep-later dedup moved none. **The XBRL renderer's files are cut (#160):** in the same `` case, a document whose `` is `XML` is removed from the input. That is the type EDGAR gives every file its renderer generates (`R1.htm`…, `Show.js`, `report.css`, `FilingSummary.xml`, described `IDEA: XBRL DOCUMENT`). The viewer pages carry `` elements, and so does 2011's `Show.js`, in a string literal, so the R-file tables followed the filed documents. The rule keys on the type, not the `R\d+.htm` name: across 79 sampled 2014–2026 indexes every one of 1,081 R files is typed `XML`, no filed document is, and inline-XBRL primaries carry their form type. Only the 4 corpus submissions holding renderer files change, each new text a prefix of the old. Indented headings stay undetected (centered S-4 headings; Items indented in 2 of 29 sampled 10-Ks): admitting indentation fills the outline cap with centered cover legends and promotes indented TOC rows. The one exception is a same-line titled Item opening on a single NBSP (#176, see Heading detection). **A primary the archive does not serve (#158, #213):** many 2000–2001 indexes list sequence-numbered documents (`0001.htm`, `0001.txt`, `0002.txt`) that answer 404 under those names. SEC serves them under the accession-prefixed name (`-0001.htm`, each a ``-wrapped copy: TOSCO's 10-Q 0000899681-00-000406 answers 404 as `0001.htm` and 200 with 74,863 bytes prefixed), so a sequence-numbered name that 404s is read again under that spelling in the same directory before the next candidate CIK is tried (`archiveSpellings`). `primary_document` and `filing_url` then name the spelling read, `documents` keeps the index's names, and a caller-named `document` resolves in either spelling, the given one read first (`requestedReadNames`). With `document` omitted, the prefixed read is optional like the rendering read (#216, `unlessSecUnavailable`): one SEC cannot serve (no response, or a 5xx — a 504 included — once its retries are spent) counts as not served and leads on to the full submission below, while a rate-limit refusal, a 403, or the caller's cancel still ends the call; a caller-named spelling's read fails as any read does. Only for a filing serving neither spelling, when no candidate CIK serves the index's primary, does `resolveFilingArchive` read the full submission `.txt` instead: `primary_document` and `filing_url` name the `.txt`, and the notice (composed into the truncation guidance, since `notice` is last-wins) names the unserved file. The fallback runs after the CIK loop, so a CIK that serves the primary still wins, and a served primary costs no extra request. A caller-named `document` is never swapped — a listed one served under neither spelling fails `document_not_found` with a hint naming `.txt`. The extract cache stores the document and CIK each text came from, so a cache hit reports the same primary, notice, `cik`, and `filing_url` as the miss that filled it, with no candidate-CIK search. **In-document links are read in the same conversion (#239):** given a links array, `filingToExtract` marks the string `convert()` receives — after `preservePlainTextBodies`, so the body rules above run first — with a sentinel (U+FDD0, a decimal index, U+FDD1) before the first text character of each fragment link (``) and of each linked target, and `stripSentinels` takes them back out of the output in one pass, recording each one's offset. A target is the first element whose `id` is the fragment, else the first `` whose `name` is, as a browser resolves it. The first text character after a start tag passes over tags, comments, `script`/`style` bodies, and html-to-text's whitespace (` \t\r\n\f`, U+200B), literal or as a character reference (`;` optional, as the parser reads it). A sentinel right after the start tag changed the text: a blank line where an empty target `div` opens a block, a leading space where a target's text opens on ` ` or an empty link sits before a `​` cell. At the next text character all 61 sampled 2008–2026 documents (57 with links) convert byte-identically to the unmarked conversion. No links are read, and the text converts unmarked, when the input already holds U+FDD0 or U+FDD1 (literal or as a numeric reference) or when the marked input would pass the parser's 16,777,216-character `maxInputLength`; a target with no text after it (a proxy card's images) records no link. The scan is one forward pass: a start tag reads `<` to the next `>`, a `<` with no `>` after it or a comment, `script`, or `style` with no closer ends the scan, and the marks waiting for text are all placed at the next text character. `search_filings`' pre-2001 scan passes no links array and converts as before. The links are cached with the extract (`CachedExtract.links`), so a cache hit lands `section` where the miss did (Section matching)
- **Heading detection:** `detectHeadings` runs three line-anchored patterns — the all-caps forms, the mixed-case same-line `Item N. Title` / `Part II` forms (#71), and a bare `Item N` marker whose title lands on a later line. **One NBSP may open a same-line Item line (#176):** both Item arms admit a single U+00A0 ahead of the marker, since an ` ` before a styled span left Horizon Space's 8-K line `Item 1.01. Entry into a Material Definitive Agreement.` (0001929980-26-000542) out of an outline that listed Items 7.01 and 9.01. A lookahead holds the NBSP to an Item marker; the entry's offset is the marker's (1960, not the NBSP's 1959), `isWrappedProse` and `followsMidSentence` still read the line from its real start, and a line that is a bare marker once its page cell is stripped (`ITEM 2.02`, `Item 1.    3`) yields nothing (`itemEntryOffset`). Nothing wider: ASCII-space or tab indentation, two NBSPs, and NBSP-led Part lines stay out — Microsoft's 2004 10-K (0001193125-04-150689) stamps an NBSP-led `Part I` on every page, and admitting those turns its body `PART I` (3832), `PART II` (64076), and `PART IV` (308565) into running-header furniture. Across the 67-filing corpus the change adds that one entry and removes none. Every whitespace match is horizontal-only: `\s` matches `\n`, which fused runs of consecutive all-caps lines into composite entries (`"TABLE OF CONTENTS\n\nPART I"`) (#105). Horizontal-only is not space-or-tab — filings put NBSP inside all-caps headings too (`MICROSOFTCORPORATION`), so the all-caps arm nests the class-set difference `[\s--\n]` in its class (the `v` flag). It is one class, not the class alternated with `[^\S\n]`: the quantified alternation slows sharply on very long lines, while a single class scans in linear time. The all-caps line admits `-`, `–`, `—`, digits, and apostrophes, and `isAllCapsHeadingText` bounds it and narrows the last two. Its heading text runs at most `MAX_TITLE_CHARS` (140): past it the line is a paragraph set in capitals, which the widened class otherwise admitted (Alternative Ballistics' 308-character risk-factor coda took `section: "business"` by substring from `OUR BUSINESS`; EuroSPAC's 860-character tax legend; GT Biopharma's 288- and 326-character voting notices). A digit is admitted only inside a whitespace-delimited `\d{1,2}[A-Z]?\.?` token judged on the whole line, a TOC page cell included (`NOTE 10`, `SCHEDULE 14A`; `USD 2,929`, `FMR LLC(4)`, `WASHINGTON, D.C. 20549`, and `…ACT OF 1934` stay out), and an apostrophe only inside a word or after a word-final `S` (`MANAGEMENT’S`, `STOCKHOLDERS’ EQUITY`), never opening or closing the heading. The all-caps `ITEM …` arm takes the rest of its line whatever it holds, and neither Item arm admits a line that runs a table of contents together: a second Item marker — after whitespace, or abutting the period, digit, or parenthesis that ends the cell before it (`Item 2.Item 3.`, `Item 9.01Item 9.02`, #180), while an opening quote or parenthesis before it is a title citing another Item (`(Item 408 of Regulation S-K)`) — or a page cell mid-line followed by two or more spaces and the next cell (`isRunTogetherToc`). Without it a 238-character 8-K table of contents (`ITEM 8.01. OTHER EVENTS. 3     ITEM 9.01. …`, Amazon's 0001104659-26-107526) was one heading and took `item 8`, `item 9`, and `exhibits`. The test is on shape, not length: a 140-character bound on that arm dropped a proxy's one proposal heading, Modine's 185-character `ITEM 1 – TO APPROVE THE PROPOSAL TO AMEND THE Amended and Restated Articles…` (DEF 14A 0001104659-26-115309), and turned `item 1` and `proposal` into misses. Across 104 cached filings (244 all-caps and 568 mixed-case Item lines) the shape test flags only the Amazon line, and no all-caps Item line runs past 240 characters, so the arm carries no outer length bound. The mixed-case same-line arm keeps its title bound as its prose guard. A colon stays out: every colon line it admitted across 26 sampled documents was a statement caption (`CURRENT ASSETS:`). A Part line may carry a title — whitespace, an optional `-`/`–`/`—`/`:`/`.`, then an uppercase letter, at most 140 characters — and is a cross-reference rather than a heading (`isPartCrossReference`), on the mixed-case and the all-caps arm alike, when it sits under a line ending on a comma or semicolon like an unpunctuated Item, or when its title opens on another marker (`Part II — Item 5. Market for Registrant’s Common Equity`, a bank 10-K's cross-reference index row), which would otherwise also displace the bare body Part it names. After dedup, a bare Part entry gives way to a later titled heading for the same numeral (`dropBarePartsTitledLater`, which applies the same marker-title test): a 10-Q's TOC row `Part II` and its body `PART II — OTHER INFORMATION` have different keys, so without the merge `section: "part ii"` landed on the TOC row 50,045 characters early (Apple Q3 FY2026). Over the 26-document corpus these rules add 72 headings and remove 4 TOC Part rows: 66 section headings (all 18 of Microsoft FY2026's `NOTE N — TITLE` lines, statement titles, titled Parts) and 6 furniture (statement captions, `SCHEDULE 14A`, a `PAGE` stamp, a wrapped legend line), no numeric table row (#170). The Item code spans `16A`–`16K` and the current 8-K's dotted `N.NN` (`ITEM_CODE`, shared by all four Item patterns), and the marker's period is optional — 20-F filers write the lettered markers unpunctuated. A dotted code may also abut its title or carry a paragraph suffix (`Item 9.01Financial Statements…`, `Item 9.01(d).    Financial…`, the shapes html-to-text leaves when the marker and title are separate spans), and then the title must start with a letter, so a bare `Item 5.02.` line never matches on its own. **Any other code may abut its title too (#180):** past its period (`Item 2.Management’s Discussion…`, `Item 1A.Risk Factors`, `Item 16A.Audit…`), the title again opening on a letter, while a digit after the period stays part of the code (`Item 2.02` and the dotted forms above read as before; `Item 2.0 something` is no heading). With no period, a code or Part numeral runs into a title only where a capital followed by a lowercase letter opens it (`TITLE_RUN_IN`, a case-sensitive `(?-i:)` lookahead inside the case-insensitive patterns): `Item 1Financial Statements` is Item `1` and `Part IFinancial Information` Part `I`, while a lettered code or a longer numeral reads whole (`Item 16KCybersecurity`, `Part IVExhibits`) and an all-caps run-in (`Item 1ARISK FACTORS`) matches nothing. New Peoples Bankshares' 10-Q (0001903596-26-000307) prints every body marker this way and had no Item in its outline; it now lists all eleven Items and Part I (28 → 40 entries; the period form alone 28 → 38), `item 1` lands on Part I's `Item 1Financial Statements` (4167) instead of Part II's `Item 1.Legal Proceedings` (113660), and `risk factors` and `management's discussion` land at 114156 and 73945 instead of missing. Across the other 25 corpus filings no outline entry or landing moved. Every new alternative is a fixed-width lookahead, so the scans stay linear. A code in `CURRENT_EIGHT_K_ITEMS` takes a 160-character title bound (`EIGHT_K_MAX_TITLE_CHARS`) on the same-line arm and in `usableTitle` — Item 5.02's 146-character title is the longest across the 53 sampled 8-Ks, and the 14-character margin admits a short trailing note while a paragraph opening on the marker line (`Item 5.02(b) On June 2, 2026, the Board…`) runs to hundreds of characters and stays out — and every other marker keeps the 140-character prose guard, a dotted code outside the table (`Item 7.09`) included. Across 53 current 8-Ks the outline holds all 112 item codes EDGAR lists for them (24 before); without the bound exemption it holds 89, without the abutting/suffix form 103 (#165). When an all-caps marker line (`ITEM 2.02`) both matches the all-caps arm alone and joins the title below it, and both entries survive dedup at one offset, the bare entry is dropped — one heading, listed once. Because the period is optional, an unpunctuated same-line Item is dropped as wrapped prose (`isWrappedProse`) when its next word starts lowercase or the line above ends on a comma or semicolon, a title run straight into its code included (`…in Part II,` over `Item 8Financial Statements of this report`, #180). A line ending on a lowercase word is no evidence: a running page header (`Table of Contents`) sits directly above an unpunctuated body heading (`Item 5 — Other information`) at the top of a page. A plain-text 10-K's cross-reference that wraps to start a line with `Item 8 on this Form 10-K…` otherwise takes the `item 8` landing ahead of the real heading (#136). **A bare marker is admitted on the shape of what follows, not just on a title being there:** a blank line between marker and title admits the pair; without one, the pair is admitted only when no bare page-number line sits under the title, since marker/title/page-number on three tight lines is the TOC-cell artifact #71's guard exists to reject. That test is one-way — it rejects a TOC row, it does not certify a heading — so a marker line anywhere else (a page footer, an unwrapped paragraph) is admitted too, taking the next line as its title; the entry carries the marker's own offset, so `section` still lands in the right place. A heading text repeated `RUNNING_HEADER_MIN_OCCURRENCES` (5) times at distinct offsets is page furniture — C3is' FY2025 20-F repeats `TABLE OF CONTENTS` 129 times and a 10-Q stamps `PART I` on all 47 of its pages, while a heading printed once in the TOC and once in the body occurs twice. **Furniture is then handled by shape, two ways:** a bare marker (`Item 1`, `PART I`) carries no navigable text, so no occurrence of it earns an entry and every one is dropped; anything else keeps its FIRST occurrence and drops the rest, inverting the keep-later rule — a filing that runs a section's own heading across every page of that section (`NOTES TO THE CONSOLIDATED FINANCIAL STATEMENTS` through an S-1's financial statements) prints it first at the section start, and keep-later would land it on the section's last page instead. So the outline carries no fused composite and at most one entry per running header, at its first occurrence. **A bare marker is counted on its own line, never on the joined heading:** a page footer's marker takes its title from whatever prose the next page opens with, so every composite it produces is a different string and only the marker line repeats — count composites instead and a 10-Q's per-page `Item 1` footer yields one bogus entry per page, crowding the real Items past `maxEntries`. That count is per rendered form (`Item 1` and `Item 1.` are separate), since a filing stamping the bare form on every page still writes the punctuated one for the real heading. Dedup (keep the later, i.e. body, occurrence) keys on `headingKey` — `foldForHeadingMatch`, a terminal-punctuation strip, and the separator between an Item/Part marker and its title folded to one space — never raw lowercase: the same filing prints one marker with different space characters and a trailing period in its TOC and its body (`Item 3.` with U+0020 vs U+2009; `…Policies` vs `…Policies.`), and a raw key leaves both in the outline. The separator is the third difference: Worthington Steel's 10-Q (0001968487-26-000036) prints `Item 1A. Risk Factors` in its TOC and `Item 1A. – Risk Factors` in its body, so both kept an entry and `risk factors` landed on the TOC row at 4292 instead of 250397. A marker key is rebuilt as the marker, one space, and the title (`item 1a risk factors`), whatever period, dash, or colon stood between them (`MARKER_TITLE_RE`, exported for the section matcher); across the 67-filing corpus that drops the 10-Q's 11 TOC copies (34 → 23 entries) and moves no other outline entry (#174). A title run straight into its marker gets its space where detection ends the marker, before the fold lowercases the capital that marks it (`RUN_IN_MARKER_RE`): `Item 1Financial Statements` keys as `item 1 financial statements`, not `item 1f inancial statements`, so it dedups against a TOC row's `Item 1. Financial Statements`, `dropBarePartsTitledLater` reads `Part IFinancial Information` as a titled Part I, and `section: "part i"` lands on it as Part I's heading (#180). `section` reads the same space in through `spaceRunInMarker`: the Item/Part test (`isStructuralHeading`, which the outline cut shares) and `sectionMatches`' operands (`foldForSectionMatch`, heading and value alike), so the numeral ends there too — `part i` reaches New Peoples' `Part IFinancial Information` (4136) in tier 2 instead of through the line tier with a line notice, and a run-in Part past the 50th heading stays in a cut outline, while `part i` never reaches `Part IIOther Information` or `Part IVExhibits` and `item 1` never `Item 12Security Ownership`. A run-in line keys one character longer than it prints, so the line scan's length short-circuit allows that one character. The furniture count, and the copy furniture keeps, key on the printed form instead — `headingKey` short of the separator fold (`printedKey`) — so a TOC row and a body heading never pool with a running header printed with another separator and lose the body heading to the TOC row. A TOC row's trailing page cell — a page number or a lettered page (`F-3`), optionally after a `Page` word (`Page 1`), set off by a tab or two or more spaces (`TOC_PAGE_TAIL_RE`) — is stripped from every heading for the same reason. Without the `Page` form, First Bancorp's 8-K TOC row `Item 8.01 Other Events            Page 1` (0000765207-26-000110) kept its cell, never deduped against the body `Item 8.01 Other Events.`, and took `item 8` at 1998 instead of 2227. A number after a single space is the heading's own (`PROPOSAL NO. 1`, `ARTICLE 5`, `…Securities Exchange Act of 1934`): the single-space strip cut it off, and dedup then merged GT Biopharma's three `PROPOSAL NO. N` headings into one `PROPOSAL NO.` at the last (#172). Across 81 cached filings the change moved nothing else in any outline. Lettered sub-item markers (a bare `A.`/`D.` inside an Item) are deliberately not detected — single letters recur throughout filing prose — and neither is a digit-led sub-item line (`3D. RISK FACTORS`; the all-caps arm needs a leading letter): `section` reaches a labelled line by stripping the label at comparison time instead (#175, see Section matching), so no detected heading is added.
- **Section matching:** `get_filing`'s `section` compares through `foldForHeadingMatch` (`filing-to-text.ts`) on **both** operands — Unicode whitespace runs collapse to one space, U+2018/U+2019 → `'`, U+201C/U+201D → `"`, then lowercase. Comparison-time only: `FilingHeading.heading`, the `outline` output, `content[]`, and every character offset keep the document's own bytes. EDGAR headings carry NBSP runs and typographic quotes, so a caller re-sending an outline heading through a UI that normalizes either one never byte-matched the heading it came from — which broke the `section_not_found` recovery loop the error's own hint points at (#106). No fuzzy matching — `"item 8"` resolves only to Item 8 — but every tier runs on folded operands, so a single-spaced needle also reaches a heading whose marker carries an NBSP run or a double space (`Item  9.` in a 2004 10-K). Folding only one operand does not fix the round trip. One boundary (`sectionMatches`): a needle ending in a number never continues into a longer one. A trailing digit never runs on into another digit, so `item 1` matches `Item 1.` and `Item 1A` but never `Item 12`, and `item 5` reaches an 8-K's `Item 5.02` while `item 5.0` does not. A trailing roman numeral (a final word of `i`, `v`, `x`) must end at a space, punctuation, or the heading's end, so `part i` never matches `PART II`, `PART III`, `PART IV`, or `PART II, III`: Sono-Tek's 10-Q (0001171520-26-000305), which prints Part I only inside a one-line TOC, landed `part i` on `PART II - OTHER INFORMATION`, and Microsoft's FY2026 10-K on `PART II, III` (348204). `part i` now falls through to the line tier (Microsoft: the body `PART I` at 6900) or misses with the outline (Sono-Tek; an S-1, whose Part I is the unlabeled prospectus). Plain-text outlines keep TOC rows worded differently from their body heading, and a TOC row for Item 10–16 precedes the body Item 1 — a bare substring test landed `item 1` on another Item in 6 of 37 plain 10-K/10-Q outlines and 1 of 270 HTML landings (#136). **Resolution order (#169, #166, #177)**, first hit wins, over every detected heading (`detectHeadings(text)`, which takes no cap, run once per call — the 50-entry cap belongs to the rendered outline alone; with the first 50 only, a heavy 10-K's financial-statement captions put Items 9B–16 out of reach) and the document's own lines: (1) **exact** (`resolveSection`) — a heading whose `headingKey` (the dedup key: the fold, a terminal-punctuation strip, and the marker separator folded to one space) equals the needle's, or whose title after its Item/Part marker does — failing that, the same with a leading sub-item label removed (below); a bare marker (`ITEM 1.`, `Part II`) has no title and is never a candidate, or GSBD's `item 1` jumps to a bare TOC `ITEM 1.`. The tier exists for #170's widened all-caps class: CCC's `SPECIAL NOTE REGARDING FORWARD-LOOKING STATEMENTS AND RISK FACTORS` (7562) otherwise takes `risk factors` from `Item 1A. Risk Factors.` (67351). (2) **Item/Part substring** — the first Item or Part heading (`isStructuralHeading`, bare and run-in markers included) that `sectionMatches`: a value inside an Item title names that Item, while a line equal to it is often a table-row label (CCC's `compensation` → `Item 11. Executive Compensation` at 493087, not the `Compensation` cell at 418658). (3) **an equal line** and (5) **a table-row label, else a prefix line** — a document line (`matchSectionLine`, `filing-to-text.ts`, which reports whether its line ranks in tier 3; `findSectionLine` is its offset alone), for a mixed-case subheading the outline never lists (`Liquidity and Capital Resources`): a line whose `headingKey` equals the needle's and that labels no table row (3), else — only when no such line exists — the first equal line that labels one (5, below), else one that starts with it (5) at a word boundary within `MAX_TITLE_CHARS` (a date-suffixed title: `Consolidated Schedule of Investments as of June 30, 2026`), unless a one-word needle runs on into a longer Title Case title (`continuesIntoLongerTitle`: the next word opens on a capital in a line that is not all caps). One word heads many unrelated titles — `business` landed on `Business Combinations.` (C3is), `Business Seasonality and Product Introductions` (Apple 10-Q), and a cross-reference line `Business Overview—Governmental regulations…` (DRDGOLD); a needle of two or more words keeps its continuation (`Human Capital` → `Human Capital Management`), and a suffix opening lowercase or on punctuation (`(continued)`, `—Parent Company Only`) always does. The cost: `forward-looking` no longer reaches `Forward-Looking Statements`, nor `revenue` `Revenue Recognition`; both miss with the outline. Equality goes first because a prefix match alone lands `Competition` on an earlier line that merely begins with the word. **A table-row label ranks in tier 5, ahead of a prefix line (#184):** an equal line labels a row when its next line with content (spacer lines passed over) is a figure row, or is a label — at most 60 characters, opening uppercase, ending on no `.`, `:`, `;`, or `,`, closing quotes and brackets passed over (a cross-reference `See “Item 18. Financial Statements.”` over its page number is no label) — whose next line with content is one (`labelsTableRow`; Microsoft's segment table `Revenue` / `Client` / `$ 9,350 …`). A figure row is a lone currency or nil cell (`$`, `-`, `–`, `—`), or a line whose text after its last letter holds a digit and, when the line has a letter, opens on a dotted leader, a tab, or two or more spaces behind a label of at most 60 characters (`isFigureRow`: `Product sales.......  $4,759`; ``$ 7,746    $ `7,835``, a stray backtick in a 2004 cell); one space never qualifies (`The effective rate was 21%.`), and past the label bound the line is a table set on one line, which a heading may sit over (AMC Robotics' S-1 0001493152-26-047958: MD&A `Revenue` at 177926 over `Three months ended June 30,  2026  2025  $ Change …`). All 13 `Revenue` lines in Microsoft's 2004 10-K (0001193125-04-150689) label rows, and as tier-3 lines took `revenue` from `SEGMENT PRODUCT REVENUE/OPERATING INCOME (LOSS)` (81018) to the selected-data row at 66987; Aware's `Revenue:` over `Product sales.......  $4,759` (112947) took it from `CONTRACT REVENUE` (49489). Demoted rather than skipped, a row label still lands when no heading contains the value (`total revenue` keeps its income-statement row), under the plain line notice. The test reads at most two content lines past an equal line. Over 26 corpus filings × 104 section values it labels 124 lines on the resolution path, none a detected heading, and moves 22 landings and no outline entry: 15 from a row label onto a heading or prose subheading for the value (Microsoft FY2026 `revenue` → its MD&A `Revenue` subheading at 217873; `earnings per share` → the EPS note in both Microsoft 10-Ks), 2 onto a table's caption line, 1 onto another row label (BAB's `goodwill`, from its balance-sheet row to a note table's), and 4 onto a heading that only contains the words (Arxis `net income` → `NOTE 16. NET INCOME (LOSS) PER SHARE`, and Nutanix's → `NOTE 11. NET INCOME (LOSS) PER SHARE` past a `Net income` row of `—` cells; CCC `net income` → the all-caps row label `NET INCOME (LOSS) INCLUDING NON-CONTROLLING INTEREST`, a detected heading; CCC `revenues` → `COST OF REVENUES` at 332106, since all four of its `Revenues` lines label rows). In both line tiers a line is skipped when it opens lowercase (a wrapped sentence or bullet); when it sits under a line ending on a comma or semicolon, or opens on an unpunctuated Item marker followed by a lowercase word (a wrapped cross-reference, the `followsMidSentence` / `isWrappedProse` guards heading detection applies: `…in Part II,\nItem 8 on this Form 10-K…`); or when it is a TOC row (`isTocRow`). A TOC row ends in a page cell set off by **two or more** spaces or a tab (`TOC_PAGE_TAIL_RE`; one space would read the year in `as of June 30, 2026` as a page number), or its next line with content is a page cell (`PAGE_CELL_LINE_RE`: a number, `F-3`, or a lowercase roman `ii`, which counts only on its own line — `Part    iv` is not a page cell); either form may put a `Page` word before the number (`Page 3`), spacer lines passed over (an HTML TOC table puts NBSP rows between its cells). A line that is not a marker, followed directly by an Item/Part marker, is a TOC title cell with no page column; a marker run straight into its title counts (`MARKER_LINE_RE` takes `TITLE_RUN_IN`, #180: `Item 2Properties`). A marker line — a Part over its first Item — is a TOC row when that Item row is: the Item line ends in a page cell, the line below it holds or ends in one (a title cell, or Aware's wrapped `of Equity Securities.......  12`), or, for a bare marker, the line below its title holds one. Microsoft's FY2026 10-K prints body `PART IV` only as page furniture, so `part iv` reaches the line tier, where `PART IV / Item 15. / Exhibit and Financial Statement Schedules / 93` was taken; it now lands on the body's first `PART IV` (352884). A body Part sits over its Item too, but prose follows, not a page cell. The first remaining line wins, at its first non-blank character; one linear pass serves tiers 3 and 5, run only after tiers 1 and 2 miss, so a value landing on a tier-4 heading now pays that pass (7–11 ms on a 1 MB S-1). A cut page's guidance after a `section` landing says to pass `next_offset` as `offset` without `section`, which outranks `offset` — kept, it returns the same page. A line landing says so in the notice, composed into the truncation guidance when the page is cut (`notice` is last-wins): `No detected heading matches section '', so this page starts at the first document line that does.` — no new output field. An equal line that outranked a heading merely containing the value says `No detected heading equals section '', so this page starts at the first document line that does, ahead of headings that only contain it.` instead, since that heading does match by substring. Every #166 acceptance offset holds. (4) **other substring** — the first other heading that `sectionMatches`, after the equal line: a heading that merely holds the word took Arxis' S-1 `business` to `NOTE 3. BUSINESS COMBINATIONS` (544705) past its `Business` heading line (323578), and CCC's `revenue` to `COST OF REVENUES` (332106) past the MD&A `Revenue` subheading (256790). An exact or Item/Part heading still beats an equal line (Reddit's `use of proceeds` → the `USE OF PROCEEDS` heading); every other heading ranks after one. Over 67 corpus filings × 103 section values the order moved 24 landings, each one the validation predicted: 17 onto the section named (Microsoft FY2026 `general` → `GENERAL` at 8720, not `GENERAL RISKS`; DRDGOLD's `risk`, `directors`, `management`, and `employees` → body Items instead of TOC rows), and 7 onto a different place that is defensible but that a reader may like less, since an Item heading or a line equal to the value now outranks a heading that only holds it — JPM's 10-Q `risk` lands on Item 3, a one-sentence cross-reference, not `FIRMWIDE RISK MANAGEMENT`; `revenue` in Microsoft's 2004 and FY2026 10-Ks and Aware's on a selected-data, MD&A-table, or income-statement row label rather than a heading containing the word — the three landings tier 5's row labels move (#184, above); `summary` on Item 16 `Form 10-K Summary`; Intel's 2000 `consolidated balance sheets` on an Item 14 index row. (6) `section_not_found`. **A proxy statement keeps its headings ahead of the lines:** for a proxy or information statement form (`PROXY_FORM_RE`: DEF 14A, DEFA14A, DEFM14A, PRE 14A, DEF 14C, …), tier 1 is followed by the first heading of any kind that `sectionMatches`, then the line tiers — its ITEM headings are ballot proposals, not the Regulation S-K item taxonomy, and its lines equal to a value are mostly table cells. Under tiers 2–4 Goldman Sachs' DEF 14A (0001193125-26-117433) took `compensation` from the CD&A (123680) to `ITEM 2. … (SAY ON PAY)` (274744) and `summary` from `EXECUTIVE SUMMARY` (16953) to a Pay versus Performance column header (282286). The form is read before `section` resolves, so the `.hdr.sgml` fallback request (#126) now precedes a `section_not_found`. **Inside each heading tier a body heading ranks ahead of a table-of-contents row (#174):** a heading is a TOC row when its title line — its own line, or the next line with content after a bare or joined marker — is one by `isTocRow` (the line tiers' test), or the next line with content below it ends in a page cell (a TOC title wrapped onto a second line, Aware's `of Equity Securities.......  12`), or the title wraps once with its page cell on its own line under the wrap — the title line, one continuation line, and a `PAGE_CELL_LINE_RE` line on three consecutive lines, the continuation in the title line's case: no lowercase letter under an all-caps title line, an opening lowercase one under a mixed-case one (`of Equity Securities`) (#183). DRDGOLD's TOC row `ITEM 14.` / `MATERIAL MODIFICATIONS TO THE RIGHTS OF SECURITY HOLDERS AND USE OF` / `PROCEEDS` / `79` took `item 14` and `material modifications to the rights of security holders` at 8146 from the body heading at 433144. A body heading over one line of body text and a page number has the same three lines: across 26 filings the step without the consecutive-line and case conditions flags 47 body headings (`Item 4. Mine Safety Disclosures` / `Not applicable.` / `31`, spacer lines between or not, and DRDGOLD's `NOTE 22 ASSET HELD FOR SALE` over its page footer and `F-50`); with them it flags only that TOC row (8146) and its title cell (8155), moves those two landings, and changes no outline. An all-caps body heading over a one-word all-caps line and a page number on consecutive lines (`ITEM 4. MINE SAFETY DISCLOSURES` / `NONE` / `31`) still reads as a TOC row and lands as its tier's only match; `isTocHeading` in `filing-to-text.ts` owns the test, finding the line start from the newline before the offset since an NBSP-led Item's offset sits one past it. A TOC row still lands when it is its tier's only match. Three TOC shapes survive dedup: a row worded apart from its body heading (First Bancorp's 8-K `Item 9.01 Financial Statements and Exhibits.        Page 1` took `item 9` at 2041 from the body Item at 2402), a bare all-caps marker over its title and page cells (DRDGOLD's FY2026 20-F `ITEM 3.` / `KEY INFORMATION` / `6` took `item 3` at 6174 from `ITEM 3. KEY INFORMATION` at 34346, and nearly every other `item N` the same way), and the title cell itself (`key information`). TOC rows are ranked, never dropped or pushed behind the line tiers: the test also reads a body heading over a page number (CCC's `Item 6. Reserved.` at 234199, Reddit's S-1 `PART II` at 1021631), and behind the line tiers CCC's and Microsoft's `item 6` left the body heading for a bare TOC marker line. With the dedup-key fold, the ranking moved 83 landings over the corpus, each from a TOC row onto a body heading (DRDGOLD 35, Worthington Steel 20, GSBD 9, Amazon's 2003 10-K 8, Aware 6, First Bancorp 4, CCC 1), and no other: 78 onto the body heading the row copies, 5 onto another the value names (Amazon 2003's `part ii`–`part iv` onto its body Parts, Aware's `management` from its TOC Item 12 to body Item 7, Worthington Steel's `other information` from its TOC Item 5 to body `PART II. OTHER INFORMATION`). **A table-of-contents row that links forward lands on its section (#239):** not a tier — once resolution has picked a heading (any heading tier, the proxy order's included) that is a TOC row (`isTocHeading`), `linkedSectionStart` (`filing-to-text.ts`) looks for an in-document link (Filing content) whose text starts on the row: the heading's own line; for a heading not printed at its offset (a bare marker joined to a later title), that title's line too; then the next line with content when it is a page cell. A link counts only when its target lies past the row, so a back-link (a body heading linking to the TOC) never moves a landing, and the next row's link is not on the row. The page starts at the line holding the target's first text, since print layout can split a heading around its target (`Executive ` before it, `Compensation` after). A body heading, a line landing, a miss, and a row with no forward link land as before; no output field or notice is added. Anthropic, PBC's S-1/A (0001628280-26-068676) prints its TOC in capitals, which detection lists as headings, and each section's own heading in mixed case, which it does not, so `risk factors` took the TOC row at 10444, alone in tier 1; it now lands at 102219 (`28` / `Table of Contents` / `Risk Factors`). Ranking the row behind an equal line does not fix that filing: its offering summary's `Risk factors` (81235) and `Use of proceeds` (78206) rows come first. Over 61 sampled 2008–2026 documents × 60 section values the step moves 20 landings, each off a TOC row onto its section, and no other; a link step ahead of the heading tiers, or for line landings and misses, moved 132 body-heading landings (19 by more than 2,000 characters) and 19 line landings, some onto other sections. **A leading 20-F sub-item label is ignored (#175):** 20-F filers label sub-items (`3D. RISK FACTORS`, `B. Business Overview`), so a plain-phrase value never equalled the line. Tier 1 and both line tiers also compare the heading or line key with a leading label removed (`withoutSubItemLabel`: `/^(?:\d{1,2}[a-z]|[a-z])\. /` on the folded key, anchored, so constant-time), and the one-word longer-title rule judges the line after its label — Regencell's `B. Business Overview` keeps `business` a miss while `business overview` lands (151573). The value itself is never stripped, so `3D. RISK FACTORS` still matches its own line; a line opening on an initial (`L. John Doerr`) strips the same way and so matches only a value equal to the rest of the line. The landing is the line's first character, label included (DRDGOLD `risk factors` → 34503). Tier 1 strips only once no heading equals the value as printed, so an earlier `A. RISK FACTORS` never takes `risk factors` from a later `Item 1A. Risk Factors`; a labelled body heading still ranks ahead of a TOC row. The line tiers keep the first line equal either way: across the corpus the labelled line they land on is the 20-F sub-item itself and a later unlabelled one is narrower (DRDGOLD `business overview` → `4B. BUSINESS OVERVIEW` at 176975, not `Business overview` at 269017). Over the corpus the strip moved 23 landings, all in the three 20-Fs: 19 misses now land on the labelled sub-item, and 4 earlier landings move onto one (DRDGOLD `compensation` from the `Compensation of key management` subheading inside Item 6B, 328266, to `6B. COMPENSATION` at 324690); no filing without labelled sub-item lines changed. **The outline cut (#169):** past 50 detected headings the outline is every Item/Part heading (`isStructuralHeading`, bare and run-in markers included) plus the earliest other headings up to 50, in offset order (`selectOutline`) — shared by the first-page `outline` and the `section_not_found` message and data. When the Item/Part headings alone pass 50, each bare marker that a later titled heading for the same Item or Part repeats is left out first (`outlineStructuralHeadings`): DRDGOLD's FY2026 20-F renders all 31 TOC Items as bare markers (`ITEM 3.`), which filled the cut and pushed body Items 16A–19 out; its outline now runs `ITEM 1.` through `ITEM 19. EXHIBITS` from the body. A bare marker no titled heading repeats stays, and `section` still matches every heading. The miss message heads a cut outline `Outline (50 of N detected headings):` (else `Outline:`), and a cut first page appends `The outline lists 50 of N detected headings, every Item and Part heading among them; section matches all of them.` to the truncation guidance — `…among them, bare table-of-contents copies of titled ones aside; …` when bare markers were left out (DRDGOLD: `The outline lists 50 of 142 detected headings, every Item and Part heading among them, bare table-of-contents copies of titled ones aside; section matches all of them.`), and no Item/Part clause when the kept Item/Part headings alone pass 50, which reaches `structuredContent.notice` and `content[]` — no new output field, since the outline is emitted only on a truncated first page. A document of 50 or fewer headings renders as before.
- **Phrase search (`find`, #238):** `get_filing`'s `find` locates a phrase in the document text, for the phrases callers sent as `section` and got `section_not_found` (`net revenue retention` occurs 7 times in Anthropic, PBC's S-1/A, 0001628280-26-068676, and is no heading). `findPhrase` (`filing-to-text.ts`) folds both operands as `foldForHeadingMatch` does — curly quotes to ASCII, each whitespace run to one space, lowercase — but one code point at a time, each folded code unit keeping the offset of the code point it came from, so a phrase broken across a line or set with an NBSP matches and every offset reported is a document offset: that phrase first occurs at 21404, folded offset 21063, and a lowercase form longer than its source (U+0130) shifts no later offset. One forward Knuth–Morris–Pratt pass over the folded units with a ring of the last phrase-length offsets: linear in the text, memory bounded by the phrase and the list whatever the count. Substring and non-overlapping, counted from the document start whatever the search start, so a continuation never re-counts. `section` outranks `find`, and `find` outranks `offset`, which becomes where the search starts: the page opens at the first occurrence at or after it — at its line's start when that is at most 200 characters back, else at the first word starting within those 200, so a page can open before `offset`. `occurrences` lists up to 20 `{ offset, context }` (the match and up to 40 characters either side, whitespace runs as one space, no edge splitting a surrogate pair; `format()` renders each through `escapeMarkdownInline`) and `occurrences_total` counts the document; when more follow, the notice names the next call, the same `find` with `offset` at the last listed match's end, composed into the truncation guidance (`notice` is last-wins), whose paging sentence then says to pass `next_offset` without `find`. No outline with `find`. A miss is `phrase_not_found` (`NotFound`), its message saying how many occur before `offset` and its hint naming only the rule that fired (absent from the document, or present only before `offset`); an `offset` past the end is a search start, so it misses this way, never as `offset_out_of_range`. A blank `find` reads as omitted, as a blank `document` does — form clients send `""` — rather than failing as a blank `section` does. `section_not_found`'s recovery, in the contract and both runtime hints, points a phrase that is not a heading at `find`.
- **Binary filing documents:** a filing index routinely carries scanned pages, PDF exhibits, `Financial_Report.xlsx`, and `-xbrl.zip` — `filingToExtract` runs `html-to-text` over whatever bytes arrive and never inspects a content type, so requesting one used to return decoded binary as `content` with no error (#96). `get-filing.tool.ts` detects them with one rule, `binaryType`: the filename extension (`binaryTypeFromName`), the one signal every filing carries, plus a header-page `GRAPHIC` type for a scan named oddly — the header page is read before the body (#161). Two halves, both needed — `documents[]` entries carry `binary: true`, and `resolveFilingArchive` throws `binary_document` before `tryGetFilingDocument` runs, since the `document` input is an unconstrained string and a catalog marker alone cannot stop a caller. Extension-driven and bucket-independent on purpose: a PDF exhibit is typed `EX-99.*` and files under `exhibits`, not among the graphics.
- **Dataframes:** `secedgar_search_filings`, `secedgar_get_financials`, `secedgar_fetch_frames`, `secedgar_compare_companies`, `secedgar_get_insider_transactions`, `secedgar_get_institutional_holdings`, `secedgar_get_material_events`, `secedgar_find_holders`, `secedgar_get_beneficial_owners`, `secedgar_get_fund_holdings`, and `secedgar_company_search` (its full filtered filing history) materialize their full result set as `df_` on a shared DuckDB-backed canvas (one per tenant). The bridge reaches that canvas only through `DataCanvas.acquireShared`, so concurrent first stagings share one canvas, and when the stored canvas is gone the replacement purges the `df-meta/` records first, so describe never lists a table the canvas no longer holds; describe, drop, and the expiry sweep never mint a canvas. `secedgar_dataframe_describe` without `name` (or with `name: ""`) is refused `listing_unavailable` over HTTP with `MCP_AUTH_MODE=none`, where every caller shares one canvas; stdio and authenticated HTTP still list, and by-name describe, query, and drop are unchanged. A `name` not shaped `df_XXXXX_XXXXX` describes as no entries and drops nothing. The two ownership tools register the full parsed set (inline response is a preview capped at `limit`); `get_insider_transactions` scans at least `INSIDER_CANVAS_FILING_SCAN` recent filings when canvas is available (so small inline limits don't under-scan the window), capped at 100, and flags `dataset.truncated` when more Form 4 filings exist beyond the scanned window (#63); with a `filed_after` / `filed_before` window it reads the in-window Form 4 / 4-A rows with an XML primary document — `recent`, then the archive pages overlapping the window, newest first, stopping once the scan cap plus a sentinel row are in hand — and with a canvas parses every one up to 100 instead of the 40-filing floor, since the window already bounds the set; `dataset.truncated` is then the sentinel or unread overlapping pages, and `history_scanned_through` the oldest filing parsed (#127); a company's submissions feed also lists the Form 4s it filed as a reporting owner of another issuer (JPMorgan's 10% stake in a BlackRock fund), so a parsed document whose `issuerCik` differs from the resolved CIK, compared with zero-padding stripped, adds no row inline or to the dataframe and is counted in `filings_other_issuer` — it still counts toward `filings_scanned`, the scan floor, and `history_scanned_through`, and the no-canvas stop rule is unchanged — though a skipped filing adds no transactions, so a no-canvas call reads on through such filings until `limit` transactions are in hand, within the same scan cap (#157); its `shares_traded` is an unsigned magnitude paired with a `direction` (acquire/dispose) column, so net activity is `SUM(CASE WHEN direction='dispose' THEN -shares_traded ELSE shares_traded END)` (#46); `get_financials`' dataframe materializes the source-filing fiscal keys as `source_filing_fy`/`source_filing_fp` (renamed from bare `fiscal_year`/`fiscal_period` — every comparative period restated in one filing carries that filing's fy/fp, so `period_end` is the time key for ORDER BY/GROUP BY/windowing) (#72); `compare_companies`' dataframe holds the FULL aligned series across every period while the inline matrix is capped at `periods`, and each row carries both the aligned `period` key and the underlying XBRL `frame` (they differ for point-in-time concepts) (#85). In an annual matrix an instant enters only on one of the filer's fiscal-year ends and takes the key of the annual duration ending that day. The ends come from the companyfacts payload already in hand (`filerFiscalYearEndsWithComparatives` in `concept-series.ts`, shared with `get_financials`' annual view of an instant): each annual report's own year (the #148 rule) plus the comparative years it restates — its year-long facts ending a whole number of years earlier, within a week — so the first XBRL 10-K's prior-year balance keys too, while a year-long figure ending off the report's calendar (a December filer's trailing year to 09-30) stays out. The #148 test of a 10-Q-held annual frame keeps `filerFiscalYearEnds` (each report's own year only): it matches day of year, where a comparative year adds nothing but a wider tolerance. The key is the first `CY####` frame on any concept in the payload ending then, so a balance lands beside its fiscal year's income lines even when none is requested; with no framed duration ending on it, the calendar year in which that fiscal year's second half starts (SEC's own framing: a year ending 06-30 is that year, one ending 06-27 or 05-31 the year before — Seagate's and Western Digital's years ending 2025-06-27 are `CY2024`; SEC departs from it for the year both ended 2012-06-29, framed `CY2012`, which is why a framed key wins). Interim and cover-date instants stay out of the annual matrix, and a company-concept left with none gets a gap plus a caveat pointing at quarterly; when that empties the whole matrix, `no_comparable_data` says the values were all off fiscal-year ends and its hint names `period_type: "quarterly"` (omitting `period_type` keeps annual). Quarterly keys are unchanged. The year ends are read lazily, only when an annual matrix meets an instant (#171). The period-ends caveat checks every inline column — the newest can hold one company alone — spelling out the newest column whose ends differ and naming the others; the inline window is the newest periods of the union across companies, so a company-concept pair whose values all predate it is named in `caveats` — one line per concept, each company with its newest period and the dataframe pointer — and never in `gaps`, which keeps meaning no value in any period (#144). Each row set carries an all-nullable schema (framework's `inferSchemaFromRows` always emits `nullable: true` since mcp-ts-core 0.10.4). Per-table TTL is delegated to the framework via `RegisterTableOptions.ttlMs` (resolved in mcp-ts-core#140); bridge still tracks metadata expiry in `ctx.state` for provenance. Every producer emits the describe-then-query pointer on the branch that registers the dataframe, composed once by `dataframeGuidance(dataset)` in the bridge so the wording cannot drift; where a truncation call already fires there it rides as that call's `guidance`, since `ctx.enrich.notice` is last-wins across `notice`/`truncated` and a second call would clobber the first (#104). `secedgar_dataframe_query` detects a `row_limit`-bound result from `QueryResult.truncated` rather than `rowCount > rows.length` — `row_limit` is pushed into the query as the provider's cap, so a capped result comes back with `rowCount === rows.length` and the arithmetic cannot see it; `rowCount` is then the cap and not a total, so that branch names the cap instead of printing "of N rows", `row_count_capped` carries the same disclosure into `format()`, and the fallback comparison stays for the `registerAs` path, where `truncated` is never set. A SQL `LIMIT` exactly equal to the cap is indistinguishable from a result that holds that many rows and is reported as exact (#109). `secedgar_dataframe_query` runs framework's SQL gate with `denySystemCatalogs: true` to block `information_schema`, `pg_catalog`, `sqlite_master`, and `duckdb_*` catalog access; since mcp-ts-core 0.13.19 that also refuses a column alias, table alias, or CTE spelled like a DuckDB metadata view (`pg_class`, `duckdb_tables`) as `system_catalog_access`, whose hint says to rename it. A statement that is not a SELECT (`WITH … INSERT` included) fails as the gate's `non_select_statement`, rewrapped with the gate's own hint and without its `statementType` (#74). Raw DuckDB execution errors (missing table, syntax) are caught in `bridge.query` and re-thrown as structured `missing_table`/`invalid_sql` reasons, with DuckDB's "Did you mean …?" hint stripped so internal catalog names don't leak (#47). The framework's own classifications pass through untouched and are declared on the tool's contract (`thrownBy: 'service'`, since the handler never names them): a SELECT that prepares and then fails on the data — a `Conversion` / `Invalid Input` / `Out of Range` error, e.g. `CAST('abc' AS INTEGER)` — is `ValidationError` with `sql_execution_error` (mcp-ts-core 0.13.4; `DatabaseError` with no reason before), and the gate's `multi_statement`, `denied_function`, `sql_read_only`, and `plan_operator_not_allowed` rejections arrive with their own recovery hints — since mcp-ts-core 0.13.15 the gate also denies engine-setting and SQL-from-string functions (`current_setting`, `query_table`) as `denied_function` and a file named as a table (`FROM 'data.csv'`) as `sql_read_only`, and since 0.13.16 it refuses any unqualified `current_schema`, since the parse tree cannot tell the bare function from a column of that name — a staged or aliased column so named is read qualified (`c.current_schema`). `get_beneficial_owners` registers one row per reporting person rather than one per filing, since powers and percent of class are per person, and each row carries the filing's `purpose_of_transaction` and the person's `notes` uncut, the text the inline result bounds (#200); `get_fund_holdings` registers every position in the report while the inline list is one `limit`-sized page from `offset`, and its rows carry the fund keys (`series_id`, `registrant_cik`, `report_period_date`, `accession_number`) so a portfolio joins the 13F and 13D/G rows on `cusip`. Insider rows carry `issuer_cik` and no `cusip`, 13F rows the reverse; 13D/G rows carry both.
- **Beneficial ownership (13D/13G):** SEC replaced the legacy `SC 13D` / `SC 13G` text filings with structured XML under the `SCHEDULE 13D` / `SCHEDULE 13G` names on **2024-12-18**; the changeover is total, so a legacy-name filter matches nothing recent and a current-name filter matches nothing older. The two schedules are **separate schemas, not one shape with optional fields** — 13D repeats `reportingPersons/reportingPersonInfo` with the power fields flat on each person and the percentage in `percentOfClass`; 13G repeats `coverPageHeaderReportingPersonDetails` with the powers nested inside `reportingPersonBeneficiallyOwnedNumberOfShares`, the percentage in `classPercent`, no reporting-person CIK anywhere, and no purpose-of-transaction field at all (its item 4 is structured ownership data, which is what makes it the passive form). `beneficial-ownership-parser.ts` therefore branches per schema and dispatches on the submission type, falling back to whichever repeated reporting-person element the document carries. **Ownership powers and percent of class are per reporting person, including on joint filings** — a fund, its adviser, and its controlling principal each report the same underlying shares, so the parsed owner list is never collapsed to a group total and the percentages must not be summed. Discovery runs off the issuer's own `getSubmissions()`: SEC cross-lists a schedule under every CIK party to it, so no entity-scoped EFTS step is needed. **Filer text is data, kept as filed and bounded per field:** Item 4 `purpose_of_transaction` and each person's cover-page `notes` keep their line breaks (CR and CRLF as filed) and come back whole up to 5,000 and 2,000 characters; past that, the value is its first N characters with nothing appended and `purpose_truncated` / `notes_truncated` say so, on both surfaces. Item 4 is the substantive 13D field: 5,000 sits just above the 90th percentile of 48 sampled Item 4s (4,916), where 1,200 cut 34 of them, and uncut the 20 longest sampled make a 136.5 KB response; 2,000 clears the longest of 77 sampled notes (1,852), and a bound stays because reporting persons per filing are unbounded. When anything was cut, the cut count and the pointer to the whole text — the dataframe's uncut columns, or `secedgar_get_filing` on the accession — ride the tool's one composed notice. `format()` renders each value as a blockquote through `escapeMarkdownInline`, every line prefixed `> ` (indented under a note's list item) and the cut marker after it, behind a blank line so the marker and the next block cannot lazily continue the quote; reporting-person names, `security_class`, `issuer_name`, and the cover-page codes render escaped too (#200).
- **NPORT-P series routing:** a fund's public portfolio report covers **exactly one series**, a registrant trust holds many (iShares Trust: 390 listed series, 200+ reports for a single period), and the series identity appears **only inside the document** — neither the submissions feed nor EFTS carries `seriesId` on an NPORT-P row. Routing is therefore series-first: EDGAR's company browse accepts a series ID in place of a CIK (`getFundSeriesFilings`), which is the one SEC surface mapping a series to its accessions and which also names the registrant, so a bare series ID resolves in one request. The browse feed's registrant outranks any registrant the `fund` input implied, since `series_id` can name a series of an entirely different trust and the archive path is keyed on the registrant. The bounded header scan (`tryGetFilingDocumentHead` + `parseNportHeader`, cap `SERIES_SCAN_LIMIT`) is the fallback and the disambiguator for a trust whose series carry no listed ticker; SEC's archive host **ignores `Range`** (a ranged request answers 200 with the whole body), so the saving is client-side stream cancellation only — and the cutoff cannot cut below one read chunk, which Bun sets at 262,144 bytes, so `HEADER_MAX_BYTES` binds only on a document arriving in several chunks. **A registrant's own NPORT-P list is never the candidate set for a series-structured trust**: series fiscal quarters are staggered, so the list interleaves funds and one report at the newest period does not mean one fund — it means the fund whose quarter ends latest. The scan therefore runs on the newest batch even when that batch holds one report, and a series found there re-routes through the browse feed; only a report naming no series at all (a closed-end fund, which also carries no `classId`) is answered from the registrant's list. Candidates are ordered by **reported period, not filing date**: an `NPORT-P/A` restating an old period is filed after every report of the periods that followed it. Reports publish on a ~60-day lag, so `report_period_date` and `publication_lag_days` are first-class output and the `as_of` enrichment states both.
- **Local mirror (opt-in, `EDGAR_MIRROR_ENABLED`):** routes `resolveCik`, `tryGetCompanyConcept`, `tryGetCompanyFacts`, and `tryGetFrames` to a local SQLite mirror (framework `MirrorService`) of `company_tickers.json` + the `companyfacts.zip` bulk archive. **Under `EDGAR_MIRROR_FALLBACK_LIVE` (default on) each read goes live wherever SEC may hold something newer than the store, and the store answers when that live read fails with anything but the caller's cancel (#234).** The ticker index reads `company_tickers.json` every TTL (ETF/mutual-fund tickers above). A point read (`tryGetCompanyConcept`, `tryGetCompanyFacts`, through `pointRead`) for a filer the store holds goes live only when the filer's newest `isXBRL` filing, on any form (companyfacts takes 8-K and 424B2 facts too), was accepted on or after the day before the companyfacts checkpoint, the bulk archive's `Last-Modified` (`companyFactsCheckpoint`); otherwise the store answers, and a tag it lacks is `null` with no live read, as SEC's 404 for an unreported tag is (#141). Live, a 404 answers the store's row where it holds one, since SEC never drops a fact from a filer's companyfacts once reported, and `null` where it does not, marking nothing. That recency comes from `getSubmissions` and its cache (`filerRecency`): one submissions read per filer per TTL, none in a call that already holds the document. Point reads asking while a filer's read is in flight share it, joining another call's read only when it cannot outspend their own failure budget, and ask again under their own scope when the call that started it is cancelled; the submissions cache holds at most 160 MiB of documents by the service's size estimate (`CACHE_BYTE_LIMITS`, 448 MiB with the archive-page and full-index caches), dropping the one used longest ago, and the failure marks at most `FILER_FAILURE_LIMIT` (4,096) filers. A failed recency or live read leaves the store answering that filer without asking SEC for `FILER_RETRY_MS` (60 s), or the failure's `retryAfter` when longer, so a 429 never turns an answer the store holds into a failure. A read the call's own failure budget ended, SEC failing none of its attempts (never sent, or its only attempt cut short of its 15 s wait), marks nothing; a call whose failure budget is already spent reads the store. A filer the store does not hold (`holdsFiler`), a store with no checkpoint, or a layer that never synced reads live, and a live failure there stands, since the store holds no answer. A frames period goes live while its filing window was open at the checkpoint (`frameWindowClosesMs`: `CY####` until November 1 of the next year, a quarterly or instant period until the first day of the seventh month after its quarter), the store's frame answering a live failure and the failure standing when the store has none; a closed period answers from the store, which applies the holder-form rules (#123, #142), live on a miss. Strict mode sends nothing for any of these reads: a miss is `null`, and a layer that never synced throws. Bootstrap out-of-band with `bun run mirror:init`; refresh nightly via cron (HTTP) or `bun run mirror:refresh`. Under `FALLBACK_LIVE` the refresh schedule sets how many reads go live, not how stale answers get; in strict mode it is how current they are. The three `mirror:*` commands also ship in the production Docker image — run them against a deployed container with `docker exec  bun run mirror:`. Node/Bun only — skipped on Workers. Frames are assembled from the company-facts store, so `loc` (business location) is absent. The ingester stores a tag's own name when SEC serves it with no label (`InterestExpenseNonoperating`), so every read path treats a stored label equal to a camel-case compound tag as no label (`storedLabel`) — callers fall back to the concept's label, and `getFrames` answers an empty `label` as the live frames API does; a one-word element's label is the word itself (`Revenues`) and is kept. Read-side, so stored rows need no migration. No FTS5 — every routed lookup is exact/indexed (cik+taxonomy+tag point, taxonomy+tag scan, cik scan for the whole-company read, ticker/CIK). Mirror ingests MF fund symbols from `company_tickers_mf.json`; on the mirror path a live MF merge supplements them when `FALLBACK_LIVE` is on (superseding the mirror's bare fund rows with series/class, and covering a mirror synced before MF ingestion), so funds resolve there too (#43, #135).

---

## Config

| Env Var | Required | Default | Description |
|:--------|:---------|:--------|:------------|
| `EDGAR_USER_AGENT` | **Yes** | — | User-Agent for SEC compliance. Format: `"AppName contact@email.com"`. A value this runtime's `fetch` cannot send as a header (a character past Latin-1 such as an em dash, or an inner line break) fails startup as a `ConfigurationError` naming the variable, rather than every request failing later (#173). |
| `EDGAR_RATE_LIMIT_RPS` | No | `10` | Max requests/second to SEC APIs. Do not exceed 10. |
| `EDGAR_RATE_LIMIT_COOLDOWN_SECONDS` | No | `600` | Seconds to stop sending to SEC after a 429. Calls are refused locally for this long, then the first one goes out alone as a probe. SEC lifts its block only after ten quiet minutes, so a shorter value just probes into it. |
| `EDGAR_TICKER_CACHE_TTL` | No | `3600` | Seconds to cache the ticker index (company_tickers.json + company_tickers_mf.json). A failed fund-file load is retried after 60 s (or the rate-limit cool-down) instead of standing for the TTL. |
| `EDGAR_DATASET_TTL_SECONDS` | No | `86400` | Per-table TTL for canvas-registered dataframes, counted from when the table is staged; queries do not extend it. |
| `EDGAR_DATAFRAME_DROP_ENABLED` | No | `false` | Set to `true` to expose `secedgar_dataframe_drop`. TTL handles cleanup otherwise. Off, the tool is registered through `disabledTool()`: absent from `tools/list` and uncallable, but rendered on the HTTP landing page in a `disabled` group carrying the reason and `EDGAR_DATAFRAME_DROP_ENABLED=true`, so an operator can see the capability exists. The list `createApp()` receives — and `buildServerManifest()`'s `definitionCounts.tools` — is 17 either way; `/.well-known/mcp.json` carries no per-tool definitions at all, only capability flags, so nothing changes there (#103). |
| `EDGAR_CLAMP_OVERSIZED_LIMITS` | No | `true` | Serve a count or size input above the cap its tool applies at that cap (up to each field's rejection bound) instead of rejecting it, so the call returns what the cap returns. An ID list past its cap — `compare_companies`' `companies` (10) and `concepts` (8 distinct), bound 100 — is answered up to the cap and the rest returned unread in `deferred_companies` / `deferred_concepts`, with one log line and one `secedgar.input.clamped` increment per deferred list (#201). Set `false` to advertise each cap as the field's maximum and reject anything larger. Read before `createApp()` from its own variable, like `EDGAR_DATAFRAME_DROP_ENABLED`; every other server variable is validated in `setup()` behind the configuration banner (#197). |
| `EDGAR_MIRROR_ENABLED` | No | `false` | Enable the local SQLite mirror of company_tickers + XBRL company-facts. Node/Bun only (skipped on Workers). Bootstrap once with `bun run mirror:init`. |
| `EDGAR_MIRROR_PATH` | No | `./data/edgar-mirror` | Directory holding the mirror SQLite databases (tickers + companyfacts). |
| `EDGAR_MIRROR_REFRESH_CRON` | No | — | In-process nightly refresh cron (HTTP transport only). Recommended `0 9 * * *`. Omit to refresh out-of-band via `bun run mirror:refresh`. |
| `EDGAR_MIRROR_FALLBACK_LIVE` | No | `true` | Read the live SEC API wherever SEC may hold something newer than the mirror — the ticker index every TTL, a filer's XBRL facts once it has filed XBRL since the store was compiled, a frames period still open for filing then, and anything the mirror lacks or has not synced — with the mirror answering when that live read fails (#234). Set `false` for strict mirror-only reads, which send nothing to SEC for ticker, XBRL-facts, or frames lookups. |
| `CANVAS_PROVIDER_TYPE` | No | `duckdb` | Canvas engine. Set to `none` to disable the canvas (e.g. on Cloudflare Workers). |

---

## Services

| Module | Path | Purpose |
|:-------|:-----|:--------|
| `EdgarApiService` | `src/services/edgar/edgar-api-service.ts` | Paced HTTP client with the rate-limit block gate, CIK resolution, all SEC API calls |
| `submissions-archive` | `src/services/edgar/submissions-archive.ts` | `SubmissionsArchiveWalk` — the one walk over `filings.files[]` archive pages: shared page cap, caller-chosen order, early stop, scan depth and truncation |
| `call-scope` | `src/services/edgar/call-scope.ts` | `CallScope` — one tool call's `ctx.signal` plus its 40 s SEC failure budget (`CALL_FAILURE_BUDGET_MS`), passed to every `EdgarApiService` method that reaches SEC (#179) |
| `filing-headers` | `src/services/edgar/filing-headers.ts` | SEC submission-header parsers: per-document types and the submission's form / filing date / period |
| `concept-map` | `src/services/edgar/concept-map.ts` | Static friendly name → XBRL tag mapping; `resolveConceptTarget` picks the taxonomy + tag list; `findUnknownConcept` rejects names that are neither |
| `trigram-similarity` | `src/services/edgar/trigram-similarity.ts` | Dice trigram scorer behind company and concept near-match suggestions |
| `concept-series` | `src/services/edgar/concept-series.ts` | Shared frame dedup + tag selection (priority or per-filer coverage); one value per standard calendar period within each unit key, and the default-unit ranking; filer fiscal-year ends; series-staleness caveats |
| `fiscal-periods` | `src/services/edgar/fiscal-periods.ts` | Fiscal-Q4 frame caveat and per-filer missing-quarter detection |
| `eight-k-items` | `src/services/edgar/eight-k-items.ts` | 8-K item-code decode tables for both numbering regimes; shape-driven `decodeEightKItem` |
| `xml-nodes` | `src/services/edgar/xml-nodes.ts` | Prefix-insensitive tag lookup + value coercion shared by the SEC XML parsers |
| `ownership-parser` | `src/services/edgar/ownership-parser.ts` | Form 4 ownership XML, 13F information tables, and the 13F cover page (filer name, period) |
| `beneficial-ownership-parser` | `src/services/edgar/beneficial-ownership-parser.ts` | Sibling SCHEDULE 13D / 13G parsers with a submission-type dispatcher |
| `nport-parser` | `src/services/edgar/nport-parser.ts` | NPORT-P identity block (cheap, for series routing) and full portfolio report |
| `filing-to-text` | `src/services/edgar/filing-to-text.ts` | HTML and SGML-submission → readable plain text (plain-text bodies keep their lines, every document of a submission in order); heading detection and the comparison fold `section` matches through |
| `CanvasBridge` | `src/services/canvas-bridge/canvas-bridge.ts` | Adapter over framework `DataCanvas`: `df_` minting, all-nullable schema derivation, per-table TTL, shared-canvas acquisition through `DataCanvas.acquireShared` |
| `sql-gate-extras` | `src/services/canvas-bridge/sql-gate-extras.ts` | System-catalog SQL deny on top of the framework's read-only gate |
| `EdgarMirror` | `src/services/edgar/mirror/` | Opt-in local SQLite mirror (framework `MirrorService`) of company_tickers + XBRL company-facts; ready-gated read helpers back `resolveCik`/`tryGetCompanyConcept`/`tryGetCompanyFacts`/`tryGetFrames` |

---

## Context

Handlers receive a unified `ctx` object. Key properties:

| Property | Description |
|:---------|:------------|
| `ctx.log` | Request-scoped logger — `.debug()`, `.info()`, `.notice()`, `.warning()`, `.error()`. Auto-correlates requestId, traceId, tenantId. Dual-sink: Pino **and** `notifications/message` to the client, so treat it as client-visible. |
| `ctx.state` | Tenant-scoped KV — `.get(key)`, `.set(key, value, { ttl? })`, `.delete(key)`, `.getMany(keys)`, `.list(prefix, { cursor, limit })`. Accepts any JSON-serializable value; reads return its JSON form (a `Date` comes back as an ISO string). |
| `ctx.requestInput` | Suspend and ask the caller for more input — `return ctx.requestInput({ inputRequests: { key: inputRequired.elicit({ message, requestedSchema }) } })`. Never returns; the handler is re-entered with the answers. Always present. |
| `ctx.inputs` | The request's responses — `.accepted(key, schema)`, `.view(key)`, `.state()`, `.dropped` — limited to what the client declared (`elicitation` and its form/url modes, `sampling`, `roots`). Client-supplied: a consent gate trusts only a `ctx.state` record it stored when it asked, bound to the operation, caller, and target (see the `api-context` skill). |
| `ctx.clientCapabilities` | What the client declared for this request, `undefined` when no view exists. Decides whether to ask for optional context (e.g. roots); never a reason to skip a consent prompt. |
| `ctx.enrich` | Success-path agent context (empty-result notices, query echo, pagination totals) — `ctx.enrich(...)` or `.notice()` / `.total()` / `.echo()` / `.truncated()`. Reaches `structuredContent` and `content[]`; lands only when the definition declares an `enrichment` block (no-op otherwise). |
| `ctx.content` | Non-text content blocks — `.image(data, mimeType)`, `.audio(data, mimeType)`, or `ctx.content(block)` for a raw block. Prepended to `content[]` after `format()`; never enters `structuredContent`. |
| `ctx.signal` | `AbortSignal` for cancellation. |
| `ctx.requestId` | Request ID — the one every log record of the call carries and its error envelope returns as `data.requestId`. |
| `ctx.tenantId` | Tenant ID from JWT; `'default'` for stdio or HTTP with auth off. |

---

## Errors

Handlers throw — the framework catches, classifies, and formats.

**Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` on `tool()`. The handler then receives `ctx.fail(reason, msg?, data?)` typed against the reason union, and `data.reason` is auto-populated for observability. The `recovery` field is required (≥5 words, lint-validated). The framework puts it on the wire whenever a failure carrying that `reason` arrives without a hint — a bare `ctx.fail('reason')` or a service throw with `data: { reason }` — as `data.recovery.hint`, mirrored into `content[]` text unless the message already contains it verbatim; override with an explicit `{ recovery: { hint } }` when runtime context matters. The fill happens at the handler boundary, so a test asserting the hint on a direct `definition.handler(...)` throw either keeps the throw site's `...ctx.recoveryFor('reason')` or asserts through `runToolContract`. Every error envelope also carries `data.requestId`, the id the server's log records for that call carry, and `content[]` closes with `(reason … · request )`. Mark an entry raised below the handler — a module-level helper, a service, the canvas bridge — with `thrownBy: 'service'` so `error-contract-unthrown` skips it; lint-only metadata, nothing at runtime reads it. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely without declaration. **All EDGAR tools that have known failure modes use this pattern.**

```ts
errors: [
  { reason: 'company_not_found', code: JsonRpcErrorCode.NotFound,
    when: 'The company input does not resolve to a CIK',
    recovery: 'Use a ticker symbol or 10-digit CIK number for an exact match.' },
],
async handler(input, ctx) {
  if (!match) throw ctx.fail('company_not_found', `Company '${input.company}' not found.`);
}
```

**Service-layer pattern (no `ctx`).** Throw an `McpError` with `data: { reason, recovery: { hint } }`, and mark the tool's matching `errors[]` entry `thrownBy: 'service'`. The auto-classifier preserves `data` on the wire so clients see the same `error.data.reason` they'd see from `ctx.fail`. The tool error text closes with a term line rendering only what `data` carries — `(reason )` for a reason alone, `· retryable` / `· not retryable` appended when `data.retryable` is a boolean, `· retry after ` when `data.retryAfter` is set (`rate_limited` carries the seconds left in SEC's cool-down), then `· request ` — so tests assert that `content[0].text` contains the diagnostic rather than pinning it exactly. A contract entry's `retryable` reaches `data` only through `ctx.fail`; a service throw sets `data.retryable` itself (#122).

**Fallback for ad-hoc throws** (no contract entry fits, prototype code): use error factories or plain `Error`.

```ts
import { notFound, validationError } from '@cyanheads/mcp-ts-core/errors';
throw notFound('Item not found', { itemId });            // explicit code
throw new Error('Item not found');                       // auto-classified → NotFound
```

**HTTP responses.** `httpErrorFromResponse(response, { service, data })` from `/utils` maps the full status table (401/403/408/422/429/5xx) and captures body + `Retry-After`. Used in `EdgarApiService` with `captureBody: false`: the framework's capture awaits each read with no stall bound or signal, so the service reads the 500-byte diagnostic itself through `readBody` (#202) and passes it as the error's `cause`, never on `data`: the framework keeps off the wire only a body its own capture read, so a body on `data` reaches the caller as given, while the call's failure log record writes each cause's `data` under its `requestId` (#244).

See framework CLAUDE.md and the `api-errors` skill for the full reference.

---

## Structure

```text
src/
  index.ts                              # createApp() entry point — stateless sessionMode, teardown stops the SEC pacer and closes the mirror
  config/
    server-config.ts                    # EDGAR env vars (Zod schema); readToolGates() reads the two tool-list switches before createApp()
  services/
    edgar/
      edgar-api-service.ts              # Paced HTTP client + rate-limit block gate, CIK resolution
      call-scope.ts                     # Per-call cancel signal + SEC failure budget (#179)
      submissions-archive.ts            # Shared archive-page walk (page cap, order, early stop)
      filing-headers.ts                 # SEC submission-header parsers
      listing-codes.ts                  # Ticker-spelling readings (venue codes, preferred classes, class roots) + non-US listing note
      former-tickers.ts                 # Former-ticker index and readings (#237)
      concept-map.ts                    # Friendly name → XBRL tag mapping + taxonomy/tag resolution
      concept-series.ts                 # Shared frame dedup, tag selection, staleness caveats
      trigram-similarity.ts             # Dice trigram scorer for near-match suggestions
      fiscal-periods.ts                 # Fiscal Q4 / missing-quarter caveats
      eight-k-items.ts                  # 8-K item-code decode tables (current + legacy regimes)
      xml-nodes.ts                      # Shared XML tag lookup + value coercion helpers
      ownership-parser.ts               # Form 4, 13F information table + cover page parsers
      beneficial-ownership-parser.ts    # SCHEDULE 13D / 13G parsers + dispatcher
      nport-parser.ts                   # NPORT-P header (series routing) + full report
      filing-to-text.ts                 # HTML / SGML submission → plain text
      types.ts                          # Domain types
      data/
        former-names.json               # Committed asset: former registrant names (bun run gen:former-names)
        former-tickers.json             # Committed asset: former ticker → CIK pairs from Wayback captures (bun run gen:former-tickers)
      mirror/                           # Opt-in local SQLite mirror (framework MirrorService)
        edgar-mirror.ts                 #   two stores + ready-gated read helpers
        tickers-sync.ts                 #   company_tickers.json ingester
        companyfacts-sync.ts            #   companyfacts.zip streaming ingester (fflate)
        index.ts                        #   barrel + server-side singleton
        types.ts                        #   row shapes + constants
    canvas-bridge/
      canvas-bridge.ts                  # Framework DataCanvas adapter, df_ minting, per-table TTL
      sql-gate-extras.ts                # Bridge-layer system-catalog deny on top of framework SQL gate
  mcp-server/
    tools/ambiguous-matches.ts          # The one cut (10) of an ambiguous resolution's candidates: message list, `matches` + `total_matches` data, partial-list sentence (#229)
    tools/dataset-schema.ts             # The `dataset` output object (name, row_count, expires_at) the eleven dataframe-producing tools share
    tools/fixed-point.ts                # Fixed-point rendering of parsed numbers without rounding their digits
    tools/format-usd.ts                 # USD amounts scaled by magnitude (T/B/M/K, sign before the $) and every-digit grouping for content[] (#222)
    tools/form-filter.ts                # `forms` normalization (trim, uppercase, drop blanks) shared by search_filings and company_search (#194)
    tools/limit-clamp.ts                # Over-cap numeric inputs served at the cap (#197) and ID lists answered to the cap (#201), behind EDGAR_CLAMP_OVERSIZED_LIMITS
    tools/resolved-as.ts                # The `resolvedAs` enrichment field of the company-resolving tools (how an input was read: an unlisted ticker spelling #235, a former ticker or earlier holder #237, an entity-index match #236)
    tools/sec-upstream-errors.ts        # The `rate_limited`, `sec_unreachable`, and `sec_unavailable` error-contract entries every SEC-reaching tool spreads into its `errors`
    tools/definitions/
      index.ts                          # buildToolDefinitions() — the createApp() tool list, drop tool gated via disabledTool(), clamped tools built from clampOversizedLimits
      company-search.tool.ts
      search-filings.tool.ts
      get-filing.tool.ts
      get-financials.tool.ts
      get-snapshot.tool.ts
      get-material-events.tool.ts
      get-insider-transactions.tool.ts
      get-institutional-holdings.tool.ts
      find-holders.tool.ts
      get-beneficial-owners.tool.ts
      get-fund-holdings.tool.ts
      fetch-frames.tool.ts
      compare-companies.tool.ts
      search-concepts.tool.ts
      dataframe-describe.tool.ts
      dataframe-query.tool.ts
      dataframe-drop.tool.ts            # Opt-in via EDGAR_DATAFRAME_DROP_ENABLED
    resources/definitions/
      concepts.resource.ts
      filing-types.resource.ts
    prompts/definitions/
      company-analysis.prompt.ts
scripts/
  gen-former-names.ts                   # Offline generator for data/former-names.json (never part of the build)
  gen-former-tickers.ts                 # Offline generator for data/former-tickers.json: Wayback captures of company_tickers.json
```

---

## Naming

| What | Convention | Example |
|:-----|:-----------|:--------|
| Files | kebab-case with suffix | `search-docs.tool.ts` |
| Tool/resource/prompt names | snake_case | `search_docs` |
| Directories | kebab-case | `src/services/doc-search/` |
| Descriptions | Single string or template literal, no `+` concatenation | `'Search items by query and filter.'` |

---

## Skills

Two skill trees live at the project root, written for different readers:

| Tree | For | Written by |
|:-----|:----|:-----------|
| `framework-skills/` | The agent developing this server — framework skills (`metadata.audience: external`) plus any development-time skills of the server's own | `init` and the `maintenance` sync (framework skills); you (your own) |
| `skills/` | The agents that install the server and call its tools — tool chains, identifier sources, gates | You, deliberately, with the `add-skill` skill. Absent by default; usually none is needed |

Development skills are modular instructions. Read them directly when a task matches — e.g., `framework-skills/add-tool/SKILL.md` when adding a tool. `bun run list-skills` prints the full registry. Their directory is deliberately not `skills/`: Claude Code and Codex auto-load a plugin's root `skills/`, so a server that ships `.claude-plugin/` or `.codex-plugin/` would hand them to every agent that installs it.

A published skill in root `skills/` reaches plugin installs and the `.mcpb` bundle as-is, ships in the npm package and the Docker image, and reaches MCP clients when the server opts in with `createApp({ skills: 'skills' })`. `lint:packaging` validates the tree.

**Agent skill directory:** Copy `framework-skills/` into the directory your agent discovers (Claude Code: `.claude/skills/`, others: equivalent). Skills then load as context without referencing `framework-skills/` paths. After framework updates, run the `maintenance` skill — Phase B re-syncs the agent directory.

Available skills:

| Skill | Purpose |
|:------|:--------|
| `setup` | Post-init project orientation |
| `design-mcp-server` | Design tool surface, resources, and services for a new server |
| `add-tool` | Scaffold a new tool definition |
| `add-app-tool` | Scaffold an MCP App tool + paired UI resource |
| `add-resource` | Scaffold a new resource definition |
| `add-prompt` | Scaffold a new prompt definition |
| `add-skill` | Author a skill the server publishes in root `skills/` for the agents that use it — when one earns its place, structure, validation, delivery |
| `add-service` | Scaffold a new service integration |
| `add-test` | Scaffold test file for a tool, resource, or service |
| `field-test` | Exercise tools/resources/prompts with real inputs, verify behavior, report issues |
| `tool-defs-analysis` | Read-only audit of MCP definition language across the surface — voice, leaks, defaults, recovery hints, output descriptions |
| `security-pass` | Audit server for MCP-flavored security gaps: output injection, scope blast radius, input sinks, tenant isolation |
| `code-simplifier` | Post-session cleanup against `git diff` — modernize syntax, consolidate duplication, align with the codebase |
| `polish-docs-meta` | Finalize docs, README, metadata, and agent protocol for shipping |
| `git-wrapup` | Land working-tree changes as a commit stack — version bump, changelog, verify, commit by concern, release commit on top. No tag, no push to main; opens the release PR when the project declares release PR mode |
| `release-pr-review` | Review pass on an open release PR — simplifier + correctness review, fixes as ordinary commits on top of the stack, PR body kept in sync. Release PR mode only |
| `release-and-publish` | Fast-forward merge (release PR mode) + tag + push + npm + MCP Registry + GH Release + Docker. Picks up from `git-wrapup` |
| `report-issue-local` | File a bug or feature request against this server's own repo via `gh` CLI |
| `report-issue-framework` | File a bug or feature request against `@cyanheads/mcp-ts-core` via `gh` CLI |
| `techniques` | Catalog of response/data-shaping techniques — overflow handling, payload shaping, retrieval patterns |
| `maintenance` | Investigate changelogs, adopt upstream changes, sync skills to agent dirs |
| `orchestrations` | Chain task skills into a gated multi-phase pipeline — build-out, QA-fix, update-ship — when you can spawn sub-agents |
| `api-auth` | Auth modes, scopes, JWT/OAuth |
| `api-config` | AppConfig, parseConfig, env vars |
| `api-canvas` | DataCanvas: register tabular data, run SQL, export, plus the `spillover()` helper for big result sets — Tier 3 opt-in |
| `api-context` | Context interface, RequestContext, logger, state, multi-round-trip input |
| `api-errors` | McpError, JsonRpcErrorCode, error patterns |
| `api-linter` | Definition linter rule catalog — invoked by `bun run lint:mcp` and `devcheck` |
| `api-mirror` | MirrorService: persistent self-refreshing local mirror (embedded SQLite + FTS5) of a bulk upstream dataset — Tier 3 opt-in |
| `api-services` | LLM, Speech, Graph services |
| `api-telemetry` | OTel catalog: spans, metrics, completion logs, env config, cardinality rules |
| `api-testing` | createMockContext, test patterns |
| `api-utils` | Formatting, parsing, security, pagination, scheduling, telemetry helpers |
| `api-workers` | Cloudflare Workers runtime |

**Chaining skills into pipelines.** When the user wants a multi-phase effort — build this server out, QA-and-fix the surface, update-and-ship — *and you can spawn sub-agents*, `framework-skills/orchestrations/SKILL.md` sequences the task skills above into a gated pipeline with verification at each step. Read it to drive the run. Optional: skip it if you can't orchestrate sub-agents, and ignore it entirely if you were *spawned* as one — you've already been scoped to a single phase.

When you complete a skill's checklist, check the boxes and add a completion timestamp at the end (e.g., `Completed: 2026-03-11`).

---

## Commands

**Runtime:** Scripts use Bun's native TypeScript execution — `bun run ` is the standard invocation. `npm run ` also works (npm delegates to bun).

| Command | Purpose |
|:--------|:--------|
| `bun run build` | Compile TypeScript |
| `bun run rebuild` | Clean + build |
| `bun run clean` | Remove build artifacts |
| `bun run devcheck` | Lint + format + typecheck + security + changelog sync |
| `bun run audit:fix` | `bun audit fix` — upgrade vulnerable packages to the lowest safe version within existing ranges (`--dry-run` previews, `--latest` rewrites ranges). First response when `devcheck` flags a transitive advisory; then `bun update `, then `bun dedupe` |
| `bun run audit:refresh` | Delete `bun.lock` and reinstall. Last resort after `audit:fix`, `bun update `, and `bun dedupe` — re-resolves every ranged dep (the framework pin included) and rewrites the lockfile as `lockfileVersion: 2` |
| `bun run tree` | Generate directory structure doc |
| `bun run format` | Auto-fix formatting (safe fixes only) |
| `bun run format:unsafe` | Also apply Biome's unsafe autofixes — review the diff; they can change behavior |
| `bun run lint:mcp` | Run the MCP definition linter standalone (rule catalog: `api-linter` skill) |
| `bun run lint:packaging` | Packaging surface checks — `server.json`/`manifest.json` env-var parity, `.mcpbignore` coverage, plugin manifests, the published `skills/` tree (run by devcheck) |
| `bun run list-skills` | Print an index of available skills from `framework-skills/` |
| `bun run changelog:build` | Regenerate `CHANGELOG.md` from `changelog/*.md` |
| `bun run changelog:check` | Verify `CHANGELOG.md` is in sync with `changelog/` (used by devcheck) |
| `bun run bundle` | Build, pack, and clean a `.mcpb` for one-click Claude Desktop install |
| `bun run test` | Run tests (Vitest — use `bun run test`, not `bun test`) |
| `bun run mirror:init` | Bootstrap the local mirror (download company_tickers + companyfacts.zip). Out-of-band; resumable. |
| `bun run mirror:refresh` | Incrementally refresh the local mirror from the SEC bulk files. |
| `bun run mirror:verify` | Print mirror sync status + run sample reads. |
| `bun run start` | Production mode (`.env`-respecting transport) |
| `bun run start:stdio` | Production mode (stdio) |
| `bun run start:http` | Production mode (HTTP) |

**CI is one file.** `.github/workflows/codeql.yml` (scaffolded) is the only GitHub Actions workflow: CodeQL is GitHub-owned end to end, and the file runs only while the repo's CodeQL *default setup* is turned off. Verification — `devcheck`, tests, the release gates — runs locally; don't add a workflow that re-runs it.

---

## Bundling

`bun run bundle` produces a `.mcpb` extension bundle for one-click install in Claude Desktop. `mcpb pack` reads `.mcpbignore`, not `.gitignore`, so every root path the bundle must not ship — source, tests, docs, caches, logs, agent docs, plugin manifests, gitignored local files such as the `/data/` mirror directory — needs a root-anchored line there (`/docs/`, never `docs/`, which also strips `node_modules/*/docs/`); `lint:packaging` names each missing or unanchored line. Root `skills/` ships on purpose. The pack step is followed by `scripts/clean-mcpb.ts`, which prunes dev dependencies (`mcpb clean`) and strips four classes of `node_modules/**` content that root-anchored `.mcpbignore` patterns cannot reach: dependency-shipped agent docs — `framework-skills/`, `skills/`, `.claude/`, `.agents/`, `AGENTS.md`, and `CLAUDE.md` at a package's root, and `SKILL.md` anywhere (a `skills/` deeper in a package is runtime code and stays); platform-specific native bindings, which would otherwise lock the bundle to the platform it was packed on; node-gyp build output beside a `binding.gyp` (`config.gypi`, Makefiles, `obj.target/`, `.deps/`, object files and debug symbols), which records the packing machine's paths — compiled `*.node` addons stay; and build-only package files — the framework's `changelog/`, `scripts/`, `templates/`, and config bases (`biome.json`, `vitest.config.base.mjs`, `config/tsconfig.base.json`), and `better-sqlite3`'s `deps/` and `src/` C sources. The bundle therefore ships without the DuckDB native, and `manifest.json` sets `CANVAS_PROVIDER_TYPE=none` so the canvas is off instead of failing to load `@duckdb/node-api`: the dataframe tools fail as `canvas_unavailable` and every other tool works normally (the data tools answer inline without registering a dataframe). MCPB is stdio-only — HTTP deployments are unaffected. Delete `manifest.json` and `.mcpbignore` to opt out; `lint:packaging` skips cleanly.

**Adding an env var requires both files:** `server.json` (registry discovery, `environmentVariables[]`) and `manifest.json` (bundle install UX, `mcp_config.env` + `user_config`). `lint:packaging` (run by `devcheck`) verifies the env var names match, that every `user_config` option is wired into `mcp_config.env` as `"X": "${user_config.X}"` (the host substitutes nothing else — `"${X}"` reaches the server as that literal string), and that an optional string option carries `"default": ""`.

**README install badges** (Claude Desktop `.mcpb`, Cursor, VS Code) and the `base64` / `encodeURIComponent` config-generation commands are ship-time concerns — run the `polish-docs-meta` skill, which carries the badge format, layout, and generation snippets in `framework-skills/polish-docs-meta/references/readme.md`.

---

## Changelog

Directory-based. Source of truth is `changelog/.x/.md` — one file per released version. `CHANGELOG.md` is a generated index; never hand-edit it.

**To add a release entry:**

1. Author `changelog/.x/.md` using `changelog/template.md` as a reference.
2. Add YAML frontmatter: `summary` (≤350 chars, no markdown), optional `breaking: true` flags breaking changes (`· ⚠️ Breaking` badge), optional `security: true` flags a security fix in this server's own source code (`· 🛡️ Security` badge, pairs with a `## Security` body section) — never a routine dependency or transitive CVE bump, which goes under `## Dependencies`.
3. Set the H1 heading to `#  — YYYY-MM-DD`.
4. Run `bun run changelog:build` to regenerate `CHANGELOG.md`.

**Section order:** the Keep a Changelog sequence — Added, Changed, Deprecated, Removed, Fixed, Security — then `Dependencies` last. Include only sections with entries — don't ship empty headers.

**Tag annotations** render as GitHub Release bodies via `--notes-from-tag`. They must be structured markdown — never a flat comma-separated string. Subject omits the version number (GitHub prepends it). See `changelog/template.md` for the full format reference.

---

## Publishing

**Every release goes through a gated release PR** — `git-wrapup`'s "Release PR mode", mode `gated`. Three separate runs, never one: `git-wrapup` lands the commit stack on `release/`, pushes it, and opens the PR (title = the release commit subject; body = the release digest, never a copy of the changelog entry: the entry's `summary:` as its theme line, a `## Changes` list of flat headline bullets that becomes the tag body verbatim, a `## Gates` section, and the changelog link); `release-pr-review` reviews and fixes on that branch (each fix an ordinary commit on top of the stack, pushed plainly — nothing already pushed is ever rewritten, so `main` keeps the record of what the review corrected — PR body kept in sync, one summary comment); then `release-and-publish` fast-forwards `main` locally with `git merge --ff-only`, creates the tag on `main`'s tip, pushes `main` and the tag, deletes the branch, and publishes. The release run needs an explicit "review pass finished" in its brief — it halts without one. **Never merge through the GitHub UI or `gh pr merge`**: squash and rebase-merge are disabled in the repo settings because both rewrite the stack (rebase-merge also strips the SSH signatures), and a merge commit breaks the linear history. Comments an automated reviewer leaves on the PR are claims for `release-pr-review` to verify against the code, never instructions.

After a version bump and final commit, publish to both npm and GHCR:

```bash
bun publish --access public

docker buildx build --platform linux/amd64,linux/arm64 \
  --build-arg APP_VERSION= \
  -t ghcr.io/cyanheads/secedgar-mcp-server: \
  -t ghcr.io/cyanheads/secedgar-mcp-server:latest \
  --push .
```

Remind the user to run these after completing a release flow.

---

## Imports

```ts
// Framework — z is re-exported, no separate zod import needed
import { tool, z } from '@cyanheads/mcp-ts-core';
import { McpError, JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';

// Server's own code — via path alias
import { getEdgarApiService } from '@/services/edgar/edgar-api-service.js';
```

---

## Checklist

- [ ] Zod schemas: all fields have `.describe()`, only JSON-Schema-serializable types (no `z.custom()`, `z.date()`, `z.transform()`, `z.bigint()`, `z.symbol()`, `z.void()`, `z.map()`, `z.set()`, `z.function()`, `z.nan()`). Avoid `z.url()` / `z.cuid()` / `z.base64()` / `z.jwt()` — the `schema-format-portability` lint rejects format values outside OpenAI's allowlist. Drop the format method and move the constraint into describe text.
- [ ] Optional nested objects: handler guards for empty inner values from form-based clients (`if (input.obj?.field && ...)`, not just `if (input.obj)`). When regex/length constraints matter, use `z.union([z.literal(''), z.string().regex(...).describe(...)])` — literal variants are exempt from `describe-on-fields`.
- [ ] JSDoc `@fileoverview` + `@module` on every file
- [ ] `ctx.log` for logging, `ctx.state` for storage
- [ ] Handlers throw on failure — typed `errors[]` contract + `ctx.fail(reason, …)` when failure modes are known; factories or plain `Error` for ad-hoc throws. No try/catch.
- [ ] Tool error contracts include `recovery` strings (≥5 words)
- [ ] `format()` renders all data the LLM needs — Claude Code reads `structuredContent`, Claude Desktop reads `content[]`; both must carry the same data. Every string the server did not author (SEC text, caller input, SQL results) reads back under CommonMark as the `structuredContent` value: inline text renders through `escapeMarkdownInline` (`@cyanheads/mcp-ts-core/utils`), multi-line upstream document text (a filing body) as a fenced block sized past its longest backtick run (`markdown().codeBlock`), which keeps its line layout, and JSON meant to be copied back into a call as a code span (`markdown().inlineCode`)
- [ ] EDGAR upstream sparsity: schemas reflect real nullability; `format()` preserves uncertainty (don't fabricate facts from missing XBRL fields); tests cover at least one sparse payload
- [ ] Registered in `createApp()` arrays (directly or via barrel exports)
- [ ] Tests use `createMockContext({ errors: tool.errors })` from `@cyanheads/mcp-ts-core/testing` for tools with declared contracts
- [ ] `plugin.json` + `mcp.json` (Agent Plugins 1.0.0 — read by Codex, Cursor, GitHub Copilot, VS Code, Kiro) populated — `plugin.json` `name` = the unscoped repo name (lowercase `a-z`, `0-9`, `-`, `.` only); `version`, `description`, `author`, `homepage`, `repository`, `license`, `keywords` from `package.json`; no other top-level field, `extensions` included. `mcp.json` holds one `"type": "stdio"` entry keyed by the unscoped name, `npx -y `, and the same fixed `env` as `.codex-plugin/mcp.json`. Neither file carries a user-supplied variable (no `""`, no `${…}` besides `${PLUGIN_ROOT}`/`${PLUGIN_DATA}`): keys reach Codex through `.codex-plugin/mcp.json` `env_vars`, which works only while `.codex-plugin/plugin.json` keeps `"mcpServers": "./.codex-plugin/mcp.json"`
- [ ] `.codex-plugin/plugin.json` populated — `name`, `version`, `description`, `repository`, `license` from `package.json`; `interface.displayName` = the unscoped repo name (never the npm scope — `lint:packaging` enforces this); `interface.shortDescription` from `package.json` description
- [ ] `.codex-plugin/mcp.json` updated — server name key is the unscoped repo name; every user-supplied variable (API key, contact email, instance URL) is listed in `env_vars` so Codex forwards it from the user's environment. Never write `"KEY": ""` into `env` — an empty value replaces the user's exported key and is read as unset
- [ ] `.claude-plugin/plugin.json` populated — `name`, `version`, `description`, `author`, `repository`, `license`, `keywords` from `package.json`; inline `mcpServers` entry keyed by the unscoped repo name. Every user-supplied variable is declared under `userConfig` (`type`, `title`, `description`; `sensitive: true` for keys and tokens; `required: true` or `default: ""`) and referenced from `env` as `"KEY": "${user_config.