ADR 036 Scoped Projections And Predicate Scope - steilerDev/cornerstone GitHub Wiki

ADR-036: Scoped Projections and Predicate Scope

Status

Accepted (2026-09-30). Issue #2019. Applies to every API response that returns a scoped projection: an array or object filtered to a subset of the underlying data by a request parameter (a budget source, a user, a status slice, a date range). The first and reference instance is GET /api/source-reports.

Context

GET /api/source-reports?sourceId=… returns, per invoice, two arrays that are filtered to the requested budget source before the response is built:

  • budgetLinesForSource — only the invoice's lines funded by this source. Other sources' lines are absent, not zero-valued.
  • depositsVisibleToSource — the invoice's untagged deposits plus those tagged to this source. Deposits tagged to another source are absent.

Until #2017 these were named budgetLines and deposits, and the scoping was stated only in JSDoc and on the API Contract page.

The same reasoning error was then made repeatedly. Reviewers and implementers derived an invoice-wide fact ("is this invoice split across sources, and why?") from the source-scoped arrays: from their length, from which rows were present, or from sum(allocatedPortion) < invoiceAmount. This produced three issues (#1898, PR #1902, #1911) and one misconception shared by four independent readers: the if/else classifier in buildReportContent.ts, two JSDoc blocks in the ReportWizardPage page object, and a Scenario 18 E2E assertion. All four assumed the arrays were complete.

The failure mode is silent. The filtered array has the same type as an unfiltered one, and nothing in the value signals that rows were removed. A client that reads "one line, no foreign deposit" cannot tell "the invoice has one line" from "the invoice has one line for this source". It reaches a confident, wrong conclusion. In this domain the conclusion is printed in a document handed to a bank or a subsidy authority. #1965 established that the footnote wording in that document is audit-relevant, so a wrong inference is a false statement to an auditor.

Decision

1. The principle

A scoped projection can never be the basis for a predicate of wider scope. When a client needs a wider-scope fact, the server computes it and ships it as its own field.

The server applied the filter, so it is the only party that still has the rows the filter removed. A wider-scope predicate computed on the client from the projection is not a heuristic that is sometimes right. It is unanswerable, because the evidence it needs was removed before the response was built.

2. The converse, which carries equal weight

A scoped projection is the correct basis for a predicate of the same (or narrower) scope.

The live example is hasOwnTaggedDeposit in client/src/lib/reportContent/buildReportContent.ts:

const hasOwnTaggedDeposit = invoice.depositsVisibleToSource.some(
  (d) => d.budgetSourceId === report.source.id,
);

The question is "does this source hold a tagged deposit on this invoice?". That question is about the requested source. The projection is scoped to the same source, and its filter (budgetSourceId === null || budgetSourceId === sourceId) keeps every row the question can match. The predicate is therefore sound by construction. Shipping it from the server would duplicate a fact the response already carries exactly, and it would add no safety.

Same-scope soundness has one precondition: the projection's inclusion filter must keep every row the predicate can match. If the step-i deposit filter in sourceReportService.ts were narrowed to untagged-only, hasOwnTaggedDeposit would silently return false and the (Deposit) badge would disappear from every deposit-constituted row. The predicate's scope would not change. What would change is that the projection no longer covers it. For that reason, each client-side same-scope predicate over a projection needs a guard test that pins the coupling between the server filter and the predicate. The test must fail when the filter is narrowed. #2018 is that test for hasOwnTaggedDeposit.

This ADR must not be applied as "never read a scoped array". It is a scope-matching rule. If the rule is applied without this section, correct code is rejected, and a rule that produces false positives stops being applied.

3. The review rule

At review time, for any predicate computed from a response field that is filtered by a request parameter:

  1. Identify the projection's scope: what the server filtered the rows by (for example "this source", or "untagged or this source").
  2. Identify the conclusion's scope: what the predicate claims to be true about (for example "this invoice, across all sources", or "this source").
  3. Compare the two:
    • Conclusion wider than the projection → reject. The predicate must be a server-computed field.
    • Conclusion the same or narrower, and the projection's filter keeps every row the predicate can match → accept. Require a guard test pinning the filter/predicate coupling (§2).
    • Conclusion the same or narrower, but the filter can drop rows the predicate needs → reject. Widen the projection, or ship the predicate from the server.

Combining the two kinds is fine and common. buildReportContent.ts sets isDeposit from invoice.isSplit && hasOwnTaggedDeposit. The first operand is invoice-wide and server-shipped, and the second is source-scoped and computed on the client from a same-scope projection. Each operand is correct at its own scope.

4. The naming corollary

A scoped projection names its scope in its field name (#2017): budgetLinesForSource, depositsVisibleToSource. The JSDoc and the API Contract text stay, because they define the exact filter. The name exists so that a reader who skips that text still sees the scope. Documentation alone did not stop four readers from making the same mistake, and the name is where they look.

Each such field also documents its absence semantics: what an empty array means. budgetLinesForSource: [] means "none of this invoice's lines belong to this source". It does not mean "this invoice has no lines".

New scoped projections follow the pattern <noun>For<Scope> or <noun>VisibleTo<Scope>. A field named after the whole entity (lines, deposits, items) promises the entity's full contents.

5. Worked example: splitKind (#1911)

The wrong inference. The bank report annotates split invoices with one of two footnote families: † (the amount is only this source's portion of a line split) and ‡ (the amount is reduced by a deposit). To decide between them, the client classified each invoice by the shape of budgetLines[] and deposits[]. An invoice whose lines all belonged to source A, but which carried a deposit tagged to source B, looked byte-identical to a genuine A/B line split in the A-scoped response, because the B-tagged deposit had been filtered out. The report then printed (partial) where (less deposit) was accurate. The mirror case was worse: a report stated that a deposit was "claimed separately" when it had been apportioned back into the same source.

The server already had the answer. invoices[].isSplit was true for these invoices the whole time. It is computed server-side by a UNION over budget lines and tagged deposits across all sources, which is an invoice-wide computation over unfiltered rows. The only fault was the client's attempt to go further and infer why the invoice was split from the scoped arrays.

The resolution. The server ships splitKind: 'lines' | 'deposits' | 'both' | null. Each arm's predicate is "does this arm contain a source other than the requested sourceId". The value is derived from the UNION arms the server already evaluates, at no extra query cost. The client consumes it directly. This is the principle applied as written: the conclusion ("why is this invoice split?") is invoice-wide, the projection is source-scoped, so the predicate moved to the server. isSplit is retained as an independent cross-check (splitKind !== null iff isSplit).

Alternatives Considered

Return unfiltered arrays and let the client filter. This would make every client-side predicate answerable. It would also send other sources' lines and deposits to a view scoped to one source, and every consumer would have to reapply the filter correctly, which moves the correctness burden to more places. It was rejected. The server filters, and the server ships wider-scope facts.

Documentation only (no rename, no ADR). This is what was in place, and it failed four times. Rejected.

Ban client-side predicates over scoped arrays entirely. This would reject hasOwnTaggedDeposit and every other same-scope predicate, forcing the server to ship one boolean per question the client might ask. It was rejected because it contradicts §2 and would be ignored in practice.

Consequences

Easier

  • Reviewers apply a three-step check (§3) instead of re-deriving the argument each time.
  • Scope is visible at the point of use, in the field name.
  • Wider-scope facts have a single source of truth on the server. The server already holds the unfiltered rows.

More Difficult

  • Each new wider-scope question the client needs is a contract change: a new response field on the API Contract page, plus a server computation. This is intended.
  • Each client-side same-scope predicate carries a guard test that couples it to a server-side filter (§2). A change that narrows a projection filter must check its client-side predicates.
  • Renaming a scoped field is a breaking response change. This is acceptable here because Cornerstone ships client and server in one container from one repository and has no external API consumers (confirmed for #2017). It would need versioning if that changed.

References

  • API Contract: GET /api/source-reports, section "Source Scope — budgetLinesForSource[] and depositsVisibleToSource[] are scoped projections"
  • #1898, PR #1902: split/deposit footnote classification built on the scoped arrays
  • #1911: splitKind, the worked example (§5)
  • #2016: ReportContentRow.isSplit renamed to isPartial (diverged from SourceReportInvoice.isSplit)
  • #2017: naming corollary (budgetLinesForSource, depositsVisibleToSource)
  • #2018: guard test pinning hasOwnTaggedDeposit to the deposit filter scope
  • ADR-034: Client-Side Report PDF Generation
⚠️ **GitHub.com Fallback** ⚠️