BEHAVIOUR — Reading evidence back (module: evidenceread)
Status: implemented (#205). Every behaviour below has a passing test in
MyLegalTeam.Tests/Commands/Cases/EvidenceReadsTests.csagainstApplication/Features/Cases/Inquiry/Reads/EvidenceReadQueries.cs.Scope: #205 / PRD-005 SYS-REQ-404, and the access half of SYS-REQ-403. Sibling of
BEHAVIOUR.case-evidence(#51), which owns the write side. This module is the read side: list a case's evidence metadata, and download one item's bytes.Why it is its own slice and not part of #51: #51 shipped upload-only, which left SYS-REQ-403's access custody action with no read path to attach to and SYS-REQ-401's hash with nothing able to re-check it. An integrity record nobody can check is not chain of custody — see the requirement's own note in PRD-005 §6.4.
Implementation:
backend/MyLegalTeam.Application/Features/Cases/Inquiry/Reads/(the queries, the hashing stream),backend/MyLegalTeam.Api/Controllers/EvidenceReadsController.cs(the two endpoints). Tests:MyLegalTeam.Tests/Commands/Cases/EvidenceReadsTests.cs,MyLegalTeam.IntegrationTests/Api/EvidenceEndpointHttpTests.cs.
Feature: Listing a case's evidence
Feature: A case's evidence is listable as metadata, never as bytes
The list answers "what evidence does this case hold" — hash, file name, content type, which diligência
produced it, and when. The bytes are a separate, deliberate act (the download below).
# @BHV-evidenceread-int-001 — the list, for a caller who may see the case
Scenario: A caller who can view the case lists its evidence
Given a case with stored evidence items
And a caller who may view the case
When the client GETs the case's evidence
Then it receives one entry per item, each carrying the SHA-256, file name, content type, the
producing diligência and the upload time
# @BHV-evidenceread-inv-001 — metadata only _(property)_
Scenario: Listing never reads the stored bytes
Given a case with stored evidence items
When the list is served
Then no object is read from storage
# The row carries only the hash since #191 moved the bytes out, so this is structural rather than a
# discipline the query has to remember — but it is pinned, because a future "convenience" that folds
# the content in would silently reintroduce the memory profile the move existed to remove.
# @BHV-evidenceread-adv-001 — visibility, and no existence oracle
Scenario Outline: A caller with no standing on the case gets 404, never 403
Given a case the caller has no standing on
When they "<action>"
Then the request is refused as not-found — never a 403, which would confirm the case exists
Examples:
| action |
| list the case's evidence |
| download an evidence item |
# Same bar and the same resolver as every other case read (CaseVisibility / CaseCallerRole), so the
# read side of evidence cannot drift from the rest of the case surface.
Feature: Downloading an evidence item
Feature: The stored bytes come back, streamed, and the act is recorded
Download is a proxied, authorised endpoint — never a public or signed URL — so the application stays
between the caller and the bytes, which is what lets it record the access at all.
# @BHV-evidenceread-int-002 — the bytes come back
Scenario: A caller who may see the case downloads an evidence item
Given a stored evidence item
And a caller who may view the case
When they download it
Then they receive the bytes that were uploaded
# @BHV-evidenceread-int-003 — the access custody entry (SYS-REQ-403, the *access* half)
Scenario: Downloading records an access custody entry carrying the hash
Given a stored evidence item
When a caller downloads it
Then a custody entry for the access is on the case's audit stream, carrying the item's SHA-256
# Every download, not only some privileged export — see "The custody-granularity decision" below.
# Note this is a READ that writes: the handler raises the fact directly (IMediatorHandler.RaiseEvent)
# rather than through UnitOfWork.CommitAsync, because nothing is being committed. It persists because
# AuditLedger.AppendAsync opens its own transaction. Stated here because "a query that writes a ledger
# entry" is otherwise a surprise.
# @BHV-evidenceread-adv-002 — the stored content type is never echoed as a rendering instruction
Scenario: A hostile stored content type cannot make the browser render the file
Given an evidence item stored with content type "text/html"
When it is downloaded
Then the response serves a fixed safe type and an attachment disposition, not "text/html"
# contentType is caller-supplied at upload and never validated there (#51 deliberately does not
# constrain it). Echoing it back on the download is what would turn a stored HTML file into stored
# XSS, and an unsanitised file name into Content-Disposition injection. This slice creates the
# exposure, so this slice closes it. The stored value stays visible in the LIST, where it is data
# rather than an instruction.
# @BHV-evidenceread-adv-003 — a row whose object is gone fails loudly
Scenario: An evidence row whose stored object is missing does not serve an empty file
Given an evidence row whose object is absent from storage
When it is downloaded
Then the request fails loudly rather than returning an empty or partial file
# Never expected in normal operation — the upload writes the object BEFORE committing the row — so
# this is an unexpected fault, not a modelled Result error. Same treatment as the deliverable
# download, and the same reasoning as the file-storage design note's failure table.
# @BHV-evidenceread-adv-004 — the recorded hash is actually checked
Scenario: A download verifies the bytes against the recorded hash
Given a stored evidence item whose object no longer matches its recorded SHA-256
When it is downloaded
Then the mismatch is detected and recorded as a custody fact
# This is the behaviour that makes SYS-REQ-401 a guarantee rather than a stored string: the design
# note's failure table claims "row committed, object missing/swapped -> detected: read it back, the
# hash doesn't verify", and until this exists nothing performs that comparison.
#
# DECIDED (see the decision note below): hash WHILE streaming, rather than buffering to verify first.
# The consequence is honest and deliberate — the bytes are already on the wire when the mismatch is
# found, so this DETECTS and RECORDS tampering, it does not PREVENT serving it. Buffering to verify
# first would reintroduce the whole-file-in-memory profile that moving off bytea existed to remove.
# Prevention, where it matters (the Dossier, #98/#99), belongs to a separate verification path.
Decisions this module settles
Custody granularity — an access entry on every download. BEHAVIOUR.case-evidence's int-002 carried
a DESIGN-PENDING asking whether access is recorded "for every read or only for privileged export".
There is no privileged export: the phrase appears nowhere else in the codebase, no export endpoint exists,
and it reads as a hand-wave at the Dossier (#98/#99) written before that slice existed. Every read it is —
"who looked at this evidence, and when" is exactly what a disciplinary process gets challenged on, and
de-noising later (collapsing repeated reads by one actor inside a window) is a read-model change that
loses nothing, whereas an access never recorded is unrecoverable.
copied stays deferred. evidence-int-002 is a Scenario Outline over accessed and copied.
Only accessed has a path here; nothing can copy evidence out until the Dossier exists. That outline is
therefore split: the accessed row is covered by BHV-evidenceread-int-003 above and unskips with
this slice; the copied row stays deferred to #98/#99.
Hash-while-streaming, not buffer-then-verify. See adv-004. Detection, not prevention, on the hot
path; prevention belongs to the Dossier's own verification pass.
A fixed safe content type on download. See adv-002.
Deliberately out of scope
- The
copiedcustody action — needs the Dossier (#98/#99) to exist. - Presigned download URLs — still open in the file-storage design note, and they would bypass the access entry this module adds, so they are not a drop-in alternative to the proxied download.
- Validating
contentTypeon upload — this module makes the echo safe; constraining the write path is #51's contract and would be a breaking change there. - Retention / RGPD deletion of evidence — separate policy.
Implementation notes
Mirrors DeliverableReadQueries.cs (list + proxied download through IFileStore, same visibility
resolver, same Result<FileDownload> + File(result) controller pattern). The one thing without a
precedent there is int-003's read-that-writes: the hash is verified while streaming
(HashVerifyingStream, same file as the query handlers) rather than by buffering the whole file first, so
the custody fact (and any tamper fact) is raised only once the caller has finished draining the response.
The vertical slice: EvidenceMapper (Application/Mappings/EvidenceMapper.cs) + IEvidenceService/
EvidenceService + EvidenceReadsController (case-scoped, alongside the diligência-scoped
EvidenceController that owns the upload) + Bruno requests (endpoints-bruno/Backend/Inquiry/) +
evidence-int-002's outline split, so accessed unskips there.