Skip to main content

BEHAVIOUR — Evidence chain of custody (SHA-256) (module: evidence)

Scope: #51 / PRD-005 SYS-REQ-401, 402, 403 (404 — reading evidence back — is #205, see below). Phase 2/8 (but reused by later evidence phases). Every evidence file is hashed (SHA-256) on upload and linked to the diligência/checklist that produced it — no orphan uploads — and each custody action writes an entry into the shared tamper-evident audit ledger (#44) carrying the file's hash. This is file integrity ("is this the untampered original?"), distinct from and complementary to the audit trail's procedural record ("who did what, when"): they interlock, they are not the same thing (poc/assumptions/audit-not-court-grade.md).

Status: implemented on #191. int-002's accessed row is now implemented too — #205 gave it a read path to attach to (evidenceread-int-003); its copied row stays deferred to the Dossier. Depends on the diligência chain-link from BEHAVIOUR.Diligencia.md; #49 (the diligências) and #44 (the ledger) are on this branch's base.

The bytes are not on the EvidenceItems row. EvidenceItem (Domain) carries only metadata; the upload handler writes the bytes to S3-compatible object storage (IFileStore, added on #208) under the key {companyId}/{sha256}, before the row is added and the transaction committed — see docs/content/design-notes/file-storage.md for why that ordering and that key shape are load-bearing for the custody guarantee, not incidental.

Implementation: backend/MyLegalTeam.Domain/Cases/InqueritoPrevio/EvidenceItem.cs (the aggregate), backend/MyLegalTeam.Application/Features/Cases/Inquiry/UploadEvidence/ (the upload slice). Tests: MyLegalTeam.Tests/Commands/Cases/UploadEvidenceCommandTests.cs, MyLegalTeam.Tests/Domain/EvidenceItemTests.cs, MyLegalTeam.IntegrationTests/Api/EvidenceEndpointHttpTests.cs.


Feature: Hashing on upload (SYS-REQ-401)

Feature: Every evidence item stores a SHA-256 computed at upload

# @BHV-evidence-unit-001 — the hash is the SHA-256 of the uploaded bytes
Scenario: Uploading an evidence file computes and stores its SHA-256
Given a diligência on a case
When an evidence file is uploaded and linked to that diligência
Then the stored evidence item carries the SHA-256 of the uploaded bytes

# @BHV-evidence-inv-001 — the hash is deterministic over content _(property)_
Scenario: Identical bytes hash identically; any change flips the hash
Given any two uploads
Then equal byte content yields the same SHA-256
And content differing in any byte yields a different SHA-256
# This is what makes a later swap detectable — the recorded hash no longer matches the bytes.

# @BHV-evidence-unit-002 — a valid upload is persisted, returning its id + hash
Scenario: The assigned instructor's valid upload is stored
Given the assigned instructor and a diligência on the case
When a valid file is uploaded
Then the evidence item is persisted (once) and the result carries its id and SHA-256

Feature: No orphan uploads (adversarial — SYS-REQ-402)

Feature: An evidence file cannot be attached without a chain link

# @BHV-evidence-adv-001 — an upload with no producing diligência is rejected
Scenario: Uploading evidence not linked to any diligência/checklist is refused
Given an evidence upload that names no producing diligência (or a non-existent one)
When the upload is attempted
Then it is rejected (no orphan uploads)
And no evidence item is stored

# @BHV-evidence-adv-002 — the producing diligência must belong to the same case
Scenario: Evidence cannot be linked to another case's diligência
Given an evidence upload naming a diligência that belongs to a different case
When the upload is attempted
Then it is rejected
And no evidence item is stored

# @BHV-evidence-adv-003 — only the assigned instructor uploads evidence
Scenario: A caller who is not the case's assigned instructor cannot upload evidence
Given a caller who is not the case's InstructorAccountId
When the caller uploads evidence
Then it is forbidden (403) "NotTheAssignedInstructor"
And no evidence item is stored
# Consistent with recording diligências — evidence is the instructor's investigative work.

# @BHV-evidence-adv-004 — an outsider is refused without leaking the case
Scenario: A caller with no standing on the case cannot upload evidence
Given an authenticated caller with no membership giving them sight of the case
When the caller uploads evidence against the case's ids
Then the response is 404 "CaseNotFound" (the case is not visible under their tenant)
And no evidence item is stored
# 404 rather than 403: a 403 here would confirm the case exists to someone who may not see it.
# Same refusal as the sibling diligência endpoint, which is where the pattern is set.

# @BHV-evidence-adv-005 — a file that is not usable bytes is refused before anything is hashed
Scenario Outline: Evidence whose content cannot become bytes is refused
Given an upload whose fileContentBase64 is "<content>"
When the upload is attempted
Then it is refused with "<code>" and no evidence item is stored
Examples:
| content | code | status |
| not-base64!! | InvalidEvidenceContent | 422 |
| (blank) | FileContentBase64 | 400 |
# Two different refusals on purpose. A blank body never reaches the decoder — the validator's
# NotEmpty rejects it as a missing field (400), which is why the handler's own zero-byte guard is
# unreachable from the wire and kept only so the decode is total. A body that IS present but is not
# base64 is a semantic failure of a well-formed request, so it is the 422.
# Decoding is a "try" either way — a malformed body never throws FormatException.

# @BHV-evidence-adv-006 — an oversized file is refused before it is decoded
Scenario: An upload larger than the permitted size is rejected
Given an upload whose base64 body decodes to more than the permitted file size
When the upload is attempted
Then it is refused — code EvidenceTooLarge (422)
And no evidence item is stored
# Judged on the ENCODED length, so an oversized body is refused without ever allocating the decoded
# array — the caller does not get to choose how much memory the server commits. The same bound
# (UploadLimits) governs the deliverable upload path, so the two cannot drift apart; before it, the
# only limit was Kestrel's implicit 30 MB default, which nobody chose and which answers outside the
# error contract.

Feature: Custody actions are audited with the hash (SYS-REQ-403)

Feature: Each custody action writes a ledger entry carrying the SHA-256

# @BHV-evidence-int-001 — upload writes a custody entry with the hash
Scenario: Uploading evidence records a custody entry on the case's audit stream
Given an evidence upload linked to a diligência
When the upload succeeds
Then a custody entry (upload) is on the case's audit stream carrying the file's SHA-256 and actor

# @BHV-evidence-int-002 — access/copy also write custody entries
Scenario Outline: A custody action is recorded
Given a stored evidence item
When the item is "<action>"
Then a custody entry for "<action>" is on the case's audit stream carrying the SHA-256
Examples:
| action | status |
| accessed | implemented — see `evidenceread-int-003` (#205): an access entry is written on EVERY download |
| copied | deferred to the Dossier (#98/#99) — nothing can copy evidence out until it exists |

Type-enforced (no test)

  • PROOF-evidence-001 An evidence item is constructible only through EvidenceItem.Upload, whose producing-diligência link is a mandatory positional parameter (the ctor is private and every member is init-only) — evidence that names no chain link is unrepresentable, so half of SYS-REQ-402 is a compile-time guarantee. Note what this does not cover: the link is a Guid, so passing Guid.Empty compiles. That an empty or unknown link is refused is a runtime rule (UploadEvidenceValidator's NotEmpty, then the handler's existence + same-case check), covered by BHV-evidence-adv-001/002 — not by the type system. (compile-time, partial)
  • PROOF-evidence-002 The stored hash is computed inside Upload and exposed as an init-only property with no other writer — evidence cannot be silently re-hashed after upload. (compile-time)

Read side — SYS-REQ-404 (#205, implemented)

Evidence is no longer write-only: it can be listed (metadata) and downloaded, both proxied and authorised. Specified in its own module, BEHAVIOUR.case-evidence-read (module evidenceread, #205), which owns who may read, what the list returns, the safe echo of the stored content type, and the access custody entry. It settled the DESIGN-PENDING that used to sit on int-002 below: an access entry is written on every download (there is no "privileged export" — the phrase named nothing that exists).

Consequence for int-002 above: it is a Scenario Outline over accessed and copied — only accessed has a path today. The accessed row is covered by evidenceread-int-003 (implemented); the copied row stays deferred to the Dossier (#98/#99), which is the only thing on the roadmap that would copy evidence out of the system.


Deliberately out of scope

  • Court-grade tamper-evidence of the ledger itself (hash-chaining/notarisation) — the ledger's own concern (#44 / poc/assumptions/audit-not-court-grade.md), consumed here, not redefined.
  • Virus scanning / file-type validation on upload — a platform concern, not a legal behaviour.
  • Retention / RGPD deletion of evidence — separate policy, not this slice.