Expose securities on the MCP connector - #4277
Conversation
…s scope `2026-05-25T10-00-00.rls-multi-business-scope` switched every `tenant_isolation` read predicate to `owner_id = ANY(get_current_business_scope())`, leaving writes pinned to `get_current_business_id()`. All four securities tables were created after it (2026-08-11 / 08-13 / 08-20) and so were never in its list — they still read through the singular helper, and no later migration broadens them. The failure is a silent narrowing, not a leak: a request whose authorized scope spans several businesses sees securities for only one of them, with nothing in the response saying so. That breaks the web client's business switcher, and it would break the MCP connector harder — it forwards its resolved read scope as `x-business-scope` and echoes that scope back to the caller, so the caller is told it saw more than it did. Predicates are byte-identical to the earlier migration's, so the scraper ingestion path (a write, pinned to the explicit target) is unaffected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Everything read-side in the securities domain was addressed one security at a time and unfiltered: `securityHoldings` returns the whole portfolio with no filter, and `securityBusinessHistory` takes a single business id and always returns its entire life. There was no way to ask "every sale of these two securities last year". Adds `Query.securityExecutions(filters, page, limit, includeCharges)` returning `PaginatedSecurityExecutions`, reusing `SecurityHistoryExecution` and `PageInfo` rather than inventing a second execution shape. Newest first — deliberately the opposite of `securityBusinessHistory`, whose oldest-first order `calculateSecurityPosition` depends on to pick a position's currency. The three identity filters (`securityBusinessIds`, `isins`, `symbols`) union with each other: they are three ways of naming the same axis, so asking for one ISIN and one symbol means both securities, not the empty overlap. Dates and types narrow on top. They resolve against the request-memoized `getAllSecurityBusinesses()` instead of three more queries — which also means an id that is not a security business of this tenant resolves to nothing rather than reaching the executions feed. Two paths, because charge links and pagination do not compose. `matchExecutionsToTransactions` is greedy and one-to-one over the sets it is handed, so pairing a page's slice would let an execution on page 2 claim the cash movement belonging to one on page 1, and the same execution would report a different charge at a different page size. So `includeCharges: false` pushes the whole filter into SQL and slices with LIMIT/OFFSET, while `includeCharges: true` reuses `getSecurityBusinessHistory` per security, unpaginated, and slices in memory — capped at MAX_CHARGE_LINK_SECURITIES securities, since each one costs a full history plus a transactions query. Both paths order identically so they cannot disagree about what page 1 is. `COUNT(*) OVER ()` rides on the returned rows, so a request for a page past the end reports a total of 0 rather than the real count. That is the only inaccuracy, it costs a second round trip to fix, and it only misreports a page the caller invented. Trade and transaction types filter on the bank's own Hebrew labels, translated from the GraphQL enums by new inverses of the existing maps. The inverses are built by inverting rather than written out again — a value added to one and forgotten in the other is exactly the drift these maps exist to make loud — and kept per-map, because the bank spells the same word two ways (`פדיון` as a trade type, `פידיון` as a payment type) and one shared inverse would silently resolve one to the other. Also threads identity through so the new surface is usable across securities and from a charge: - `SecurityBusiness.ownerId`, so rows from several businesses stay distinguishable once a request's scope spans more than one. - `SecurityHistoryExecution.securityBusiness`, so a flat cross-security list can be grouped. Carried on the proto rather than derived from the row: the executions table is keyed by Poalim's key, several of which can collapse onto one ISIN, and the caller already knows which business it asked for. - `ChargeSecurity.securityBusiness`, closing the loop from a charge to the security's own identity through the key -> ISIN identifier bridge. Nullable: the reference feed can be ingested before the executions that create a security business. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The connector knew securities only as a charge *type* — a `FOREIGN_SECURITIES` value in the charge filters and one glossary entry. There was no way to ask what the tenant holds or what it traded. Adds two read-only tools: - `accounter_list_security_holdings` — the portfolio. One row per security with units held, weighted average cost, totals bought and sold, and the span of the ingested history. `includeClosed` also returns securities traded but no longer held. `search` matches name (either language), symbol, ISIN, exchange, currency and every source identifier — the same fields the web screen searches, so the two cannot drift. Upstream takes no search argument and a portfolio is tens to low hundreds of rows, so it filters here rather than growing a server filter nothing else needs. Rows sort by |quantity| descending, the screen's own default: biggest live position first, with `Math.abs` on purpose because a negative quantity is a large position badly recorded, not a small one. - `accounter_get_security_executions` — the trade history, newest first, paged. Narrow by security (ids, ISINs or symbols, which union with each other), by trade date, and by kind. `includeCharges` also resolves the charge behind each trade, and is refused here unless securities are named: upstream caps it and would answer UPSTREAM_ERROR, which reads as a server fault to retry when in fact the call was malformed. Both echo `scope.memberBusinessIds` and tag every row with `ownerId`, so a multi-business answer cannot pass for a single-business one. The holdings tool computes per-currency subtotals rather than leaving totalling to the caller. Asked what a portfolio is worth, a model will add a shekel column to a dollar one — these amounts are each security's own trade currency and are never converted. Computing the sums that *are* valid, and only those, makes the grouping structural where a caveat is only advisory; quantities and average costs are deliberately absent, since units of different instruments and per-unit prices do not add up even within one currency. Securities with nothing ingested have no currency to report in and are counted separately rather than folded into an arbitrary bucket. Both responses also carry a `caveats` array on the wire: the position is derived arithmetic over a scraped history, there is no bank balance and no market price, pre-history holdings and splits are invisible, and a null amount means "nothing ingested" rather than zero. A model that reads the rows and not the schema still has to see that. Registered between the charge/ledger drill-down and the reference-data lookups: securities are their own drill-down, with the portfolio as the entry point and the execution history as what it drills into. Also extends the registry-wide contract suites, which are the real gate on a new tool: upstream fixtures in scope-forwarding and usage-log, and both tools added to scope-contract's hand-listed BUSINESS_SCOPED_TOOLS / MULTI_BUSINESS_TOOLS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A foreign-securities charge is the one place where "what actually happened" is not in the charge itself: the cash movement is a bank row, and the trade behind it lives in the ingested portfolio feed. Asked about a securities charge, the model could see the amount and the counterparty but not the security or the trade. Adds `includeSecurities`, following `includeTransactions` / `includeDocuments` exactly — one `@include(if:)` inline fragment on the `ForeignSecuritiesCharge` member, threaded through both operations in the document. Each security reports its `securityBusinessId`, which is what `accounter_list_security_holdings` and `accounter_get_security_executions` are addressed by, so a charge answer can be followed into the portfolio rather than dead-ending. Descriptors come from the security business where both sources have them: its `currencyCode` is the resolved `Currency` enum, while the reference feed's is a raw source string that can still be a Hebrew label. Three states are kept distinct rather than collapsed to an empty list, because they mean different things: - the field is absent for any charge that is not a securities one, and for a securities charge fetched without the flag — `@include(if:)` omits it entirely, so both read as "not asked for"; - an empty array means the transaction descriptions carried no key the ingested feed knows; - `referenceFound: false` on a present security means the reference scrape is out of date for a key that *is* traded, which is worth seeing rather than dropping. Unlike `transactions` and `additionalDocuments`, which the `Charge` interface declares for every member, `securities` hangs off one union member — so it is narrowed off the raw charge rather than read from it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The connector's glossary is what stops the model reading a tool result wrong before it has seen the schema, and securities had exactly one entry in it — which was also stale. Corrects `foreign-securities-charge`: it said the charge is identified by "the securities counterparty business configured for the owner", which stopped being true when per-security businesses landed. The counterparty is now the traded security itself; the general business is only the fallback. Also states that the trade behind the cash leg has to be asked for, and that ledger generation for this charge type is still unsupported. Adds five entries, each written around the trap rather than the field name: - `security-execution` — that it comes from the portfolio feed and not the bank statement, that the two have no link in the source so the pairing is derived and exact, and that dividends and interest are execution kinds here rather than a separate entity. - `security-business` — that the ISIN is the identity, why that forces the identifier indirection, that two Poalim keys can collapse onto one security, and that it deliberately carries no suggestion phrases so a security can never win a description-based counterparty match. - `derived-position` — the four ways these numbers mislead if quoted plainly: pre-history holdings uncounted, splits invisible, a negative quantity meaning a mid-life history rather than a short, and a null amount meaning nothing ingested rather than zero. Plus that there are no market prices at all, so current value and unrealized P&L are not computable. - `poalim-security-key` — that it is parsed out of a description rather than reported, is not the ISIN, and is not comparable across brokers. - `isin` — why identity and lookup are split across two tables, and that it is the stable way to name a security without a `securityBusinessId`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🚀 Snapshot Release (
|
| Package | Version | Info |
|---|---|---|
@accounter/client |
0.1.0-alpha-20260823155124-6a7748c3640ef5374111955447d72a188a0838f8 |
npm ↗︎ unpkg ↗︎ |
@accounter/green-invoice-graphql |
0.8.7-alpha-20260823155124-6a7748c3640ef5374111955447d72a188a0838f8 |
npm ↗︎ unpkg ↗︎ |
@accounter/hashavshevet-mesh |
0.2.13-alpha-20260823155124-6a7748c3640ef5374111955447d72a188a0838f8 |
npm ↗︎ unpkg ↗︎ |
@accounter/israeli-vat-scraper |
0.1.13-alpha-20260823155124-6a7748c3640ef5374111955447d72a188a0838f8 |
npm ↗︎ unpkg ↗︎ |
@accounter/modern-poalim-scraper |
0.11.0-alpha-20260823155124-6a7748c3640ef5374111955447d72a188a0838f8 |
npm ↗︎ unpkg ↗︎ |
@accounter/payper-mesh |
0.2.13-alpha-20260823155124-6a7748c3640ef5374111955447d72a188a0838f8 |
npm ↗︎ unpkg ↗︎ |
@accounter/scraper-app |
0.0.3-alpha-20260823155124-6a7748c3640ef5374111955447d72a188a0838f8 |
npm ↗︎ unpkg ↗︎ |
@accounter/server |
0.2.0-alpha-20260823155124-6a7748c3640ef5374111955447d72a188a0838f8 |
npm ↗︎ unpkg ↗︎ |
@accounter/shaam-uniform-format-generator |
0.2.7-alpha-20260823155124-6a7748c3640ef5374111955447d72a188a0838f8 |
npm ↗︎ unpkg ↗︎ |
@accounter/shaam6111-generator |
0.1.9-alpha-20260823155124-6a7748c3640ef5374111955447d72a188a0838f8 |
npm ↗︎ unpkg ↗︎ |
Server: - The enum inverses round-trip every member of all three vocabularies. A forward map that grows a value without its label, or an inverse resolving into the wrong map, fails here rather than at query time as an empty result nobody can explain. Two cases pin the reason the inverses are per-map: the bank spells redemption `פדיון` as a trade type and `פידיון` as a payment type, while buy and sell are spelled identically across the two vocabularies. - Integration coverage for `getSecurityExecutionsPage`: newest-first ordering, non-overlapping pages against a stable total, the identity filters unioning rather than intersecting, case-insensitive symbol matching, an id outside the tenant resolving to nothing, date and type pushdown, the empty-list-means-no- restriction guard, the `MAX_CHARGE_LINK_SECURITIES` refusal, and — the regression this design exists to prevent — the match path ordering identically to the SQL path. - Integration coverage for the migration, run under the non-superuser role rather than at provider level: all four tables return every business in a multi-business scope, narrow to a single-business scope, and fall back to the single business when the GUC is unset. MCP: a new suite for both tools — absolute-quantity ordering with a negative position present, search across every field the web screen searches, per-currency subtotals and the no-currency bucket, owner tagging and scope forwarding under two memberships, 1-based-to-0-based page translation, the `includeCharges` validation refusal, and a 400-security portfolio degrading to valid JSON inside the byte budget. Plus `includeSecurities` cases on the charges detail tool covering the three states it keeps distinct, and two end-to-end tool calls over real HTTP. Two fixture-isolation fixes, both latent and exposed rather than introduced by the above. The securities lookups carry no `owner_id` predicate — RLS scopes them in production, and these suites connect as a superuser which bypasses it — so: - ISINs in this suite are now synthetic (`ZZ…`). Sharing a real one with `security-businesses.integration.test.ts`, which runs concurrently and asserts on the row it gets back for a given ISIN, is a genuine cross-suite collision. - Two `getExecutionsBySecurityBusiness` cases asserted on the whole map's size, which assumes exclusive access to the database. They now assert on this suite's own buckets. The second is also re-pointed at what it was really about: a security business with no key identifier still gets an empty bucket, which is what keeps "nothing ingested" distinguishable from "not a security". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
README: the two new tools and the `includeSecurities` flag, each written around what the numbers do not mean as much as what they do — derived positions, no market prices, per-security currencies that are never converted, and why `includeCharges` needs the securities named. Two smoke-test steps, including the same-chargeId-at-any-pageSize check that is the whole reason that path exists. Also corrects bounds this file had drifted on: the tool count (twelve, listing eleven, now fourteen), the balance report's date range and row cap (366/500 → 1096/1000), the tags cap (500 → 1000), and the blanket "date ranges ≤ 366 days, page size ≤ 50, list caps of 500" line. Every cap is an exported `MAX_*` constant the suite asserts, so the file now says that rather than restating numbers it cannot keep in step. connector-gaps-and-decisions: a new "recorded findings" section for things worth not re-deriving — the securities tables never having been in the multi-business RLS scope and the general rule that a table created after that migration inherits nothing from it; why a greedy one-to-one pairing cannot be computed from a page; and why these integration suites, which share a database and connect as a superuser that bypasses RLS, cannot assert on whole result sets. Plus the changeset. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`.changeset/config.json` ignores `@accounter-helper/*`, and changesets refuses a changeset that names both ignored and non-ignored packages — so the snapshot release failed with "Found mixed changeset". The migrations package is not published and is not versioned this way; the repo's own convention already reflects that, since `security-businesses-schema` added a migration and declared only `@accounter/server`. The migration is still described in the changeset body, which is where a reader looks for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Critical multi-business ownership and DELETE-isolation findings, plus moderate tool correctness findings, remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR exposes securities holdings, executions, and charge-linked securities through MCP, with GraphQL, RLS, tests, and documentation updates.
Changes:
- Adds holdings and paginated execution MCP tools.
- Adds
includeSecuritiesto charge details. - Adds multi-business RLS support and related coverage.
File summaries
| File | Reviewed change |
|---|---|
packages/server/src/modules/foreign-securities/types.ts |
Execution filtering, pagination, and ownership types. |
packages/server/src/modules/foreign-securities/typeDefs/security-businesses.graphql.ts |
Execution query and ownership schema. |
packages/server/src/modules/foreign-securities/typeDefs/foreign-securities.graphql.ts |
Charge-to-security relationship schema. |
packages/server/src/modules/foreign-securities/resolvers/security-businesses.resolver.ts |
Execution page and owner resolvers. |
packages/server/src/modules/foreign-securities/resolvers/foreign-securities.resolver.ts |
Charge security resolvers. |
packages/server/src/modules/foreign-securities/providers/foreign-securities.provider.ts |
Filtering, pagination, holdings, and matching logic. |
packages/server/src/modules/foreign-securities/providers/__tests__/foreign-securities.integration.test.ts |
Integration coverage for executions and RLS. |
packages/server/src/modules/foreign-securities/helpers/security-execution-enums.helper.ts |
Execution filter enum translation. |
packages/server/src/modules/foreign-securities/helpers/__tests__/security-execution-enums.helper.test.ts |
Enum translation tests. |
packages/migrations/src/run-pg-migrations.ts |
Registers the migration. |
packages/migrations/src/actions/2026-08-23T10-00-00.rls-scope-securities-tables.ts |
Applies multi-business securities RLS scope. |
packages/mcp-server/src/tools/terminology-data.ts |
Securities glossary entries. |
packages/mcp-server/src/tools/securities.ts |
Holdings and execution MCP tools. |
packages/mcp-server/src/tools/registry-instance.ts |
Registers the new tools. |
packages/mcp-server/src/tools/charge-details.ts |
Adds securities to charge details. |
packages/mcp-server/src/tools/__tests__/usage-log.test.ts |
Usage logging coverage. |
packages/mcp-server/src/tools/__tests__/securities.test.ts |
Securities tool tests. |
packages/mcp-server/src/tools/__tests__/scope-forwarding.test.ts |
Scope forwarding tests. |
packages/mcp-server/src/tools/__tests__/scope-contract.test.ts |
Scope contract tests. |
packages/mcp-server/src/tools/__tests__/detail-tools.test.ts |
Charge security detail tests. |
packages/mcp-server/src/__tests__/mcp-e2e.test.ts |
MCP end-to-end coverage. |
packages/mcp-server/README.md |
Documents the securities tools. |
packages/mcp-server/docs/connector-gaps-and-decisions.md |
Records design decisions and caveats. |
eslint.config.mjs |
Lint configuration updates. |
codegen.ts |
GraphQL security type mappings. |
.changeset/mcp-securities-tools.md |
Release notes. |
Review details
Suppressed comments (7)
packages/mcp-server/src/tools/securities.ts:188
- The
/securitiesscreen's default is numericquantitydescending (packages/client/src/components/screens/securities/index.tsx:88-90), whereasMath.absmakes a-80position sort ahead of a+12position. The PR and this function document that the tool mirrors the screen, so negative positions currently produce different ordering between the two views. Update the screen to use magnitude as well, or change this comparator and the parity claim.
Math.abs(b.quantity) - Math.abs(a.quantity) ||
packages/mcp-server/src/tools/securities.ts:362
- The input allows up to 50 values in each identity list, but
includeChargesonly supports 10 resolved securities. A valid call naming 11 matching securities therefore reaches upstream and becomes anUPSTREAM_ERROR, even though the handler's stated purpose is to classify malformed charge-link requests as validation errors. Enforce or otherwise surface the resolved-security cap before the upstream call instead of allowing this known input error through the upstream-error path.
includeCharges: z
.boolean()
.optional()
.default(false)
.describe(
packages/mcp-server/src/tools/securities.ts:566
- The summary string is missing the possessive in
each security own trade currency, so the user-facing result is grammatically incorrect and inconsistent with the tool description. Useeach security's own trade currency.
: `${total} execution(s); showing ${shown} on page ${pagination.page} of ${pagination.totalPages}, newest first. Amounts are in each security own trade currency.`,
packages/server/src/modules/foreign-securities/providers/foreign-securities.provider.ts:530
COUNT(*) OVER()is only available when the page contains a row. A request for a page after the last one therefore reportstotalRecords: 0/totalPages: 0, and the MCP tool says “No executions matched” even when earlier pages contain matches. Preserve the count with an independent count query (or equivalent) when the slice is empty sopageInforemains truthful.
// `COUNT(*) OVER ()` is identical on every row of the page.
totalRecords: rows.length ? Number(rows[0].total_count) : 0,
currentPage: page,
packages/server/src/modules/foreign-securities/providers/foreign-securities.provider.ts:557
ownerIdis one request-level write target, butbusinessIdscan contain securities from multiple owners in the read scope. If a caller asks for a B-owned security while the current target is A, this invokesgetSecurityBusinessHistory(B, A)and filters candidate transactions to A, so every B execution loses its charge link; the history's key-only execution lookup can also mix same-key rows from other in-scope owners. Resolve the owner for each security and keep both execution and transaction matching owner-scoped.
const histories = await Promise.all(
businessIds.map(businessId => this.getSecurityBusinessHistory(businessId, ownerId)),
packages/server/src/modules/foreign-securities/resolvers/security-businesses.resolver.ts:103
- These arguments are exposed to direct GraphQL callers without a positive/non-negative check, and the resolver passes them directly to
LIMIT/OFFSET. A negative page reaches a negative offset, whilelimit: 0yields no rows and makesMath.ceil(totalRecords / pageSize)NaN/Infinity; MCP's Zod validation does not protect other clients. Validatepage >= 0andlimit > 0in the GraphQL path (or enforce an equivalent schema contract).
page: page ?? 0,
limit: limit ?? DEFAULT_SECURITY_EXECUTIONS_LIMIT,
packages/server/src/modules/foreign-securities/typeDefs/security-businesses.graphql.ts:18
- These new GraphQL arguments are unconstrained beyond
Int. A direct GraphQL caller can passlimit: 0(which makestotalPages = Math.ceil(totalRecords / pageSize)becomeNaNand failIntserialization) orpage: -1(which reaches a negative SQLOFFSET); the MCP Zod schema does not protect other GraphQL clients. Validatepage >= 0andlimit > 0(and apply the intended maximum) in the resolver or schema before invoking the provider.
securityExecutions(
filters: SecurityExecutionsFilter
page: Int = 0
limit: Int = 100
" Resolve the cash movement, and so the charge, behind each execution. The pairing is greedy and one-to-one over a security's whole history, so it cannot be computed from a page — the same execution would report a different charge at a different page size. Asking for it therefore switches to an unpaginated match per security, and caps how many securities the filter may resolve to "
includeCharges: Boolean = false
): PaginatedSecurityExecutions! @requiresAuth
- Files reviewed: 26/26 changed files
- Comments generated: 5
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| const currency = | ||
| holding.totalBought?.currency ?? | ||
| holding.totalSold?.currency ?? | ||
| holding.averageCost?.currency ?? | ||
| holding.currency; |
| // Upstream takes no search argument and a portfolio is tens to low hundreds of | ||
| // rows, so filtering here costs nothing and keeps the match rules identical to | ||
| // the web screen's. | ||
| const needle = input.search?.toLowerCase(); |
| await connection.query( | ||
| sql.unsafe` | ||
| CREATE POLICY tenant_isolation ON accounter_schema.${sql.identifier([table])} | ||
| FOR ALL | ||
| USING (owner_id = ANY (accounter_schema.get_current_business_scope())) | ||
| WITH CHECK (owner_id = accounter_schema.get_current_business_id()) | ||
| `, | ||
| ); |
| const rows = await getFilteredSecurityExecutions.run( | ||
| { | ||
| securities: [...businessIdByKey.keys()], | ||
| isTradeTypes: rawTradeTypes.length ? 1 : 0, |
| (await injector.get(SecurityBusinessesProvider).getSecurityBusinessByIdentifierLoader.load({ | ||
| type: 'POALIM_SECURITY_KEY', | ||
| value: chargeSecurity.securityKey, | ||
| })) ?? null, |
What & why
The securities domain (PRs #4194 → #4270) is reachable only through the web UI. Over MCP, an
assistant knew securities as a charge type and nothing more — it could not say what the tenant
holds, what it paid, what it traded, or which security sits behind a charge.
This adds that, in three pieces, plus the server work they need to be correct rather than
plausible.
Tools
accounter_list_security_holdings— the portfolio. One row per security with units held,weighted average cost per unit bought, totals bought and sold, and the span of the ingested history.
includeClosedalso returns securities traded but no longer held;searchmatches name (eitherlanguage), symbol, ISIN, exchange, currency and every source identifier.
Search, ordering and the row cap happen in the tool: upstream takes no search argument, a portfolio
is tens to low hundreds of rows, and mirroring the
/securitiesscreen's own match and sort rules iswhat stops the two drifting. Ordering is by
|quantity|descending —Math.abson purpose, since anegative quantity is a large position badly recorded, not a small one.
accounter_get_security_executions— the trade history. Buys, sales, dividends, interest,redemptions, distributions and transfers, newest first, really paginated. Narrow by security, trade
date and kind. The three identity filters (
securityBusinessIds,isins,symbols) union witheach other: they are three ways of naming one axis, so one ISIN plus one symbol means both
securities, not the empty overlap.
includeSecuritiesonaccounter_get_charges— following the existingincludeTransactions/includeDocumentsidiom. A foreign-securities charge is the one place where what happened is not inthe charge: the cash leg is a bank row, the trade lives in a separate ingested feed. Each security
reports the
securityBusinessIdthe other two tools are addressed by, so a charge answer can befollowed into the portfolio. Three states stay distinct — absent (not a securities charge, or not
asked for), empty (no key the feed knows), and
referenceFound: false(a traded key whose referencescrape is stale).
The numbers carry their own caveats
A position is arithmetic over a scraped trade history, and the arithmetic is easy to read as more
than it is. The bank reports no holding. There are no market prices anywhere in the system, so
current value and unrealized P&L are not computable. Pre-
historyStartDateholdings and splits areinvisible. A negative quantity means a history that starts mid-life. A null amount means nothing was
ingested, not zero. Amounts are each security's own trade currency and are never converted.
Asked what a portfolio is worth, a model will otherwise add a shekel column to a dollar one. So the
holdings tool computes the sums that are valid — per currency — and emits a machine-readable
caveatsarray alongside them. Grouping is structural where a caveat is only advisory. Quantitiesand average costs are never summed at all, even within one currency.
Two things found on the way
The securities tables were never in the multi-business RLS scope.
2026-05-25T10-00-00.rls-multi-business-scopeswitched 45 tables' read predicate toowner_id = ANY(get_current_business_scope()). All four securities tables were created after it(2026-08-11 / 08-13 / 08-20) and still read through the singular
get_current_business_id(); nolater migration broadened them. Verified against a live database before fixing.
The failure was a silent narrowing, not a leak: the connector forwards its resolved scope as
x-business-scopeand echoes that scope back, so a two-business caller would be told it had seenboth while the tables had served one. The web client's business switcher has the same bug today;
this fixes both. Predicates are byte-identical to the earlier migration's, so writes stay pinned to
the explicit target and the scraper ingestion path is unaffected.
Charge links and pagination do not compose.
matchExecutionsToTransactionsis greedy andone-to-one over the sets it is handed, oldest-first. Hand it a page's slice and an execution on page 2
can claim the cash movement belonging to one on page 1 — the same execution reporting a different
charge at a different
pageSize.So
Query.securityExecutionshas two paths. WithoutincludeChargesthe whole filter pushes intoSQL and the page is a
LIMIT/OFFSETslice. With it, each named security's complete history isfetched and paired, then filtered and sliced in memory — capped at ten securities, since each costs a
full history plus a transactions query. Both paths order identically so they cannot disagree about
what page 1 is, and the tool refuses
includeChargesunless securities are named (aVALIDATION_ERRORthat says what to add, rather than anUPSTREAM_ERRORthat reads as a fault toretry).
Commits
fix(migrations)feat(server)securityExecutions;ownerIdand the charge→security bridgefeat(mcp-server)feat(mcp-server)includeSecuritiesonaccounter_get_chargesdocs(mcp-server)testdocs(mcp-server)Verification
yarn test:integrationagainst a local DB with the migration applied: 328 files, 4151 testspassing, run four times. One run had a one-off suite-setup flake in
rls-write-target.integration.test.ts(no assertion failed; it passes in isolation and in theother three runs).
yarn workspace @accounter/mcp-server test: 722 passing.yarn lint: 0 errors.yarn prettier --check .: clean apart from three pre-existinguntouched files.
pg_policies.qualnow readsowner_id = ANY (accounter_schema.get_current_business_scope())for all four tables, and thefull-migration
rls-all-tablessuite passes with it applied.Pre-existing failures, not from this PR
mcp-e2e.test.tshas two failing assertions (initializes and lists the curated tools,rejects a write tool as unknown while writes are disabled) — both expect the write tools to be hidden whenMCP_ENABLE_WRITE_TOOLSis unset. Confirmed identical on a clean stash of this branch's base(same assertion, 14 tools instead of 16). Left alone as out of scope.
Not done
Left deliberately, and called out in the plan: the web client's
/securitiesscreen and Security tabstill use
securityHoldings/securityBusinessHistoryrather than migrating onto the new query; nomarket-price or valuation work (no feed exists); the bank's per-execution
nominalProfitLoss/realProfitLossare still not aggregated into a realized-gain report; no mutating securities tool;and ledger generation for
ForeignSecuritiesChargeremains unsupported.🤖 Generated with Claude Code