Data flow
DigiFIR moves data along four paths. Writes go from a person's device to the ledger as signed actions. Shared data comes back from the ledger as read views built from blocks. Private data is read on demand with a signed read request. Files travel encrypted to object storage, while their hashes and keys go to the ledger.
Write path: a signed action
What the app does
The app builds an action: a small JSON object with its type, a new UUIDv7 ID, the signer's key ID, the institution, the time and the subject record. Fields that hold personal data or free text never go into the action. They go into a separate private payload, and the action carries only that payload's SHA-256 hash (privateHash).
The app encodes the action as canonical JSON (RFC 8785, Unicode NFC) and signs the bytes with ES256 using the key in the phone's secure hardware. It sends the action, the signature, its key ID and the private payload to POST /v1/actions. If the phone is offline, the SDK keeps the signed action in an encrypted outbox and sends it later.
What the backend checks before it relays
The api process runs these checks in order. Any failure stops the request before the ledger sees it.
- Strict parsing. Duplicate member names, lone surrogates, fractions and unsafe integers are refused as
ERR_NOT_CANONICAL. - The envelope and the body match the contract's schema for that action type.
- The key ID matches the actor in the action and the key of the session.
- The bytes are canonical, NFC-normalised and at most 64 KiB.
- Idempotency. A repeat of the same action ID with the same bytes returns the stored receipt, and with different bytes it is refused with
409. - Pre-checks that fail fast: the signature verifies against the projected key, the key is active and valid at the action's time, and the action is neither dated more than 120 seconds ahead nor older than its type allows.
- The private payload is present exactly when the type needs one. Its hash must equal
privateHash, and its size must stay at most 1 MiB (16 MiB when it carries copies for a handover). - Every attached file is already uploaded and verified.
- Routing. The action's type gives the chaincode, the function, which institution's gateway submits it, and which institutions must endorse it.
The chaincode repeats the signature, role, jurisdiction and time checks itself. The backend's checks only save a round trip.
Submitting to Fabric
The backend submits the proposal through the gateway identity of the creating institution. It always names the endorsing organisations explicitly, so routing never depends on Fabric's discovery guessing a policy. The private payload travels only as transient data to those organisations' peers and is wiped from memory afterwards.
The backend waits up to 15 seconds for the commit and then answers with the transaction ID and block number. If the commit takes longer, it answers 202 and keeps following the transaction; the app learns the outcome from its event stream.
| Outcome | What the backend does | What the app sees |
|---|---|---|
| Read conflict (two transactions touched the same key) | Re-endorses and resubmits with a fresh proposal, up to 3 times | The receipt, or a retryable 409 MVCC_CONFLICT |
A chaincode error such as ERR_ROLE or ERR_STATE | Passes the code through unchanged | The code with its HTTP status |
| An endorsing organisation unreachable | No retry inside the request | Retryable 503 ORG_UNAVAILABLE |
| Orderer unreachable or submit timeout | Keeps the transaction ID; the projector or the relay watchdog settles it | 202 |
| Transaction lost by the ordering service | The watchdog asks a peer for its status and submits the same signed action again | The receipt, or INTERRUPTED so the app resends |
Resubmitting can never apply an action twice. The chaincode writes a replay marker under the action's ID, so a second copy fails with ERR_REPLAY and the backend returns the first receipt.
Every filing at a station advances that station's complaint counter, so only one filing per station can commit in each block. The relay therefore queues a station's filings and submits them one after another.
Some steps must commit in a fixed order, for example a seizure and then its memo. These go as a bundle of up to five actions (POST /v1/action-bundles), each submitted only after the one before it has committed.
Read path: read models and events
The projector turns the ledger into tables the apps can list and filter quickly.
- It reads full blocks from a peer, starting from its saved checkpoint.
- For each block it checks the hash chain, the pinned genesis block hash, the channel and the orderer signatures under the channel's block validation policy. A block that fails changes nothing and raises a critical alert.
- It applies the whole block in one database transaction: read models, settled actions, stream events, notification rows and the new checkpoint. A crash in the middle leaves nothing half-applied.
- After the database commit it publishes each stream event to Redis.
Read models hold no personal data, and they can be rebuilt from the ledger at any time (projector --rebuild). Every page of a read view says the block it is current to (asOfBlock). While the read models are 5 seconds or more behind, responses carry an X-Digifir-Read-Lag header.
Apps follow changes through one server-sent event stream (GET /v1/events), with long polling as a fallback. Channels are scoped to what the caller may see:
| Channel | Who subscribes |
|---|---|
me | Every signed-in person: their own actions and notices |
citizen.cases | A citizen: changes to their complaints |
case:<id>, station:<code>, io:<officialId> | Readers of the case, staff of the station, and each IO for their own cases |
court:<code> | Magistrates and court staff of that court |
lab:<labId>:<division>, exhibit:<id> | Laboratory staff of the division, and readers of an exhibit |
explorer.blocks, system | The officials' console |
Events carry references only, never personal data. A push notification through FCM only wakes the app; the app then fetches the details after signing in.
Private reads
Narratives and personal details are never stored by the backend. To show them, the app signs a read request (read.private) naming the cases and parts it wants and the purpose (VIEW, PRINT, ANALYSIS, AUDIT, ESCROW or HANDOVER).
- The backend checks the signature and sends the request to a peer of an institution that is a member of the collection: the state's peer for citizens and police, the judiciary's peer for magistrates, the forensics peer for laboratory staff.
- The chaincode checks the reader's role and jurisdiction against the access rules and answers each part with the value, its collection and its private-data hash.
- The answer is bound to the request that asked for it. When a value's collection has two or more member institutions, the app uses it only when peers of two different institutions endorsed the same answer, so no single institution's peer can feed it a false value.
- The backend writes an audit log row naming the targets and parts, never the values, and answers with
Cache-Control: no-store.
Values pass through the backend's memory only. Apps keep what they read encrypted on the device.
Files
Attachments and evidence never reach the backend in plain form.
- The device encrypts the file with AES-256-GCM using a new random key, and hashes both the plain file and the ciphertext.
- It asks
POST /v1/filesfor a presigned upload URL and uploads the ciphertext straight to object storage, in 8 MiB parts for large files. Evidence media may be up to 2 GiB. - The backend recomputes the SHA-256 of what arrived. A mismatch deletes the object.
- The action that uses the file carries its hashes. The file key goes with the private payload into the state's private collection.
- When the action commits, the projector marks the file as referenced. Unreferenced uploads are deleted after 24 hours.
- To download, a person with read access to the case gets a presigned URL valid for 5 minutes, then decrypts with the key from a signed private read.
When a court-ordered erasure purges every copy of a file's key, downloads answer 410 Gone. The file's hashes stay on the ledger.
Notifications
Projector handlers write notification rows to an outbox in the same database transaction as the block. The worker sends them by FCM push, by SMS through a TRAI DLT-registered provider, or by email. Statutory notices, such as the FIR copy link under BNSS s.173(2) and the 90-day progress update under s.193(3)(ii), go by SMS through the department's sender header. Payloads carry a reference and generic text only.
Sources: digifir-backend docs/architecture/BACKEND.md sections 3, 4, 6, 7, 9 and 12, DECISIONS.md ADR-11, ADR-13, ADR-16; digifir-chaincode docs/SPEC.md section 3.10.