Chaincode overview
DigiFIR's business rules live in four Hyperledger Fabric chaincodes (smart contracts) written in Go. They are built from one Go module (github.com/jyflix/digifir-chaincode) and deployed as chaincode as a service, one server per organisation and chaincode version. Every peer of every organisation installs all four, because the chaincodes read each other during endorsement.
| Chaincode | Purpose | Details |
|---|---|---|
identity-registry | The root of authority: who signed an action, with which role and jurisdiction, and whether the key is valid now. Also stations, courts, laboratories and on-ledger configuration. | identity-registry |
fir-registry | One case record per complaint, from filing to disposal: registration, investigation, arrests, final reports, duplicate hints, statutory clocks, documents for verification | fir-registry |
evidence-custody | Exhibits, two-party custody hops, laboratory parcels, examination stages and sealed reports | evidence-custody |
judicial-actions | Court decisions and orders, applied to the case through fir-registry | judicial-actions |
The binding specification is docs/SPEC.md in the chaincode repository. Where the specification is silent or ambiguous, the reading the code takes is recorded in docs/IMPLEMENTATION_NOTES.md.
Rules every chaincode follows
These rules are enforced by tests and by seven static analysers that run as a go vet tool in CI.
| Rule | Analyser |
|---|---|
No record is ever deleted. DelState and DelPrivateData are forbidden anywhere; PurgePrivateData is allowed only inside the one audited erasure function of each chaincode. | nodelete |
Deterministic code: no time.Now outside the clock file, no map-order dependence, no randomness, no network, no goroutines, integer arithmetic only. | determinism |
| Rich, paginated, history and private reads only in the query functions allowed to make them, so no write ever depends on a CouchDB rich-query result. | reads |
| Exactly one chaincode event per transaction, emitted only through the shared event code. | oneevent |
No panic, log.Fatal or log.Panic in chaincode code. | nopanic |
Composite ledger keys are built only through the builders in shared/keys.go. | keys |
| Only the error codes in the specification's table are returned. | errcodes |
State machines only move forward. A correction is a new version of the record. The ID of a new record is the ID of the signed action that created it, a UUIDv7 chosen by the signer, and creating a record whose key already exists fails.
The shared library
Code under shared/ is compiled into all four chaincodes:
| Package | What it does |
|---|---|
canonical | RFC 8785 canonical JSON, Unicode NFC checks, refusal of hidden formatting and C1 control characters |
es256 | ECDSA P-256 signature verification over SHA-256. The chaincodes only verify; they never sign. |
action | Parsing and checking the action envelope |
verify.go | Gate 1, the twelve-step check below |
record.go, keys.go, event.go, errors.go, page.go, policy.go, clock.go | Versioned record envelopes, composite keys, the event schema, error codes, pagination bookmarks, key-level policies and the only permitted call to the peer's clock |
The same canonical-encoding rules are implemented in TypeScript and Dart for the backend and the SDKs. One set of cross-language test vectors in testvectors/ keeps the three in step.
The signed action
Every state change starts as a signed action. Its envelope:
{
"v": 1,
"type": "fir.register",
"id": "0193f2c4-...-7b",
"actor": "key:9f2c...e81a",
"org": "PoliceStateAMSP",
"at": 1761631327123,
"subject": "...",
"body": { },
"privateHash": "sha256:..."
}| Field | Meaning |
|---|---|
v | Envelope version, 1 |
type | The action type, for example complaint.file or court.cognizance |
id | UUIDv7 chosen by the signer; also the replay key and the ID of any record the action creates |
actor | A registered key ID (key: and 32 hex characters) or a citizen's <did>#k<n> |
org | The acting institution, or for a citizen the state the action concerns |
at | The signer's clock in milliseconds |
subject | The record the action applies to |
body | Type-specific public fields. Never personal data or free text. |
privateHash | SHA-256 of the private payload sent separately as Fabric transient data |
The signature is ES256 over the UTF-8 bytes of the canonical JSON, a 64-byte r||s value in base64url. The private payload holds named sections, each with its own random salt, and file keys. The chaincode writes each section to a private data collection, and Fabric's private-data hash of the stored value then equals the commitment in the body. Endorsing peers that are not members of the collection can still check those commitments, because transactions read only private-data hashes, never private values.
Gate 1: verifying a signed action
shared.Verify runs these steps in order and stops at the first failure. Steps 1 to 3 run before any ledger read, so malformed input costs almost nothing.
- Size: the action is at most 64 KiB, the private payload at most 1 MiB, or 16 MiB when it carries copies for a handover (
ERR_TOO_LARGE). - Canonical form: the bytes are canonical JSON and every string is NFC (
ERR_NOT_CANONICAL,ERR_NOT_NFC). - Envelope: the fields are well formed, the type is one the function accepts, the key ID equals the actor, and the UUIDv7's timestamp is within 24 hours of
at(ERR_BAD_REQUEST). - Signer:
identity-registry.ResolveSignerreturns the key, roles, designation, jurisdiction, validity and status. Unknown keys, revoked keys, keys used outside their validity, and session-only keys signing a write all fail here (ERR_UNKNOWN_SIGNER,ERR_SIGNER_REVOKED,ERR_SIGNER_EXPIRED,ERR_ROLE). - Signature: ES256 verifies over the received bytes (
ERR_BAD_SIGNATURE). - Time: the transaction time is within 120 seconds of the endorsing peer's clock;
atis no more than 120 seconds after the transaction time; and the action is no older than its type allows (ERR_TX_TIME,ERR_FUTURE,ERR_STALE). - Creator: the submitting gateway is the one the type requires (
ERR_CREATOR). - Replay: no marker
ACT~<id>exists yet. The function writes it in the same transaction (ERR_REPLAY). - Private payload: present exactly when required, canonical, hashing to
privateHash, with the right sections and salts (ERR_PRIVATE_MISSING,ERR_PRIVATE_HASH). - Role: the signer holds a role the function allows (
ERR_ROLE). - Jurisdiction: the record is within the signer's station, court, laboratory or state (
ERR_JURISDICTION). - Body and guards: the body's schema, referenced records exist, the current status allows the transition, domain guards pass, and public commitments match the private payload (
ERR_BAD_REQUEST,ERR_NOT_FOUND,ERR_STATE,ERR_GUARD,ERR_COMMIT_MISMATCH).
Maximum ages come from on-ledger configuration. The defaults:
| Actions | Maximum age |
|---|---|
| Citizen actions and court orders (both apps keep an offline queue) | 30 days |
| Field work: diary, statements, arrests, seizures | 7 days |
| Desk decisions | 72 hours |
| Sealing a laboratory report | 10 minutes |
| Key actions | 24 hours |
| Signed read requests | 120 seconds |
The record stores both the signer's time and the transaction time, so an action queued offline keeps its original time and the gap stays visible.
NOTE
Fabric lets the submitting client choose the transaction timestamp. Because each endorsing peer refuses a timestamp more than 120 seconds from its own clock, backdating a transaction would need the peers of two institutions to collude.
Events
Every transaction emits exactly one chaincode event, named after the action type (system.<function> for system functions). Its payload is canonical JSON carrying everything the backend's projector needs and no personal data: the chaincode, function, transaction ID, action type and ID, signer, role, institution, times, the records created or changed with their versions and statuses, a type-specific public summary, and for private writes only the collection, key, hash and operation.
Errors
A failing function returns canonical JSON {"code": "ERR_...", "msg": "...", "data": {...}} of at most 1 KiB, with no personal data. Codes are stable, and the backend passes them through to the apps with an HTTP status.
| Code | HTTP | Meaning |
|---|---|---|
ERR_BAD_REQUEST | 400 | Schema, format or unknown field |
ERR_NOT_CANONICAL, ERR_NOT_NFC | 400 | Not canonical JSON, or text not in NFC, or a hidden formatting or control character |
ERR_TOO_LARGE | 413 | A size limit was exceeded |
ERR_BAD_SIGNATURE, ERR_UNKNOWN_SIGNER | 401 | The signature does not verify, or the key is not registered |
ERR_SIGNER_REVOKED, ERR_SIGNER_EXPIRED | 403 | The key is revoked or replaced, or used outside its validity |
ERR_ROLE, ERR_JURISDICTION | 403 | The signer lacks the role, or the record is outside their jurisdiction |
ERR_CREATOR, ERR_CALLER | 403 | Wrong submitting gateway, or a cross-chaincode-only function called directly. Both indicate a backend fault and raise an alert. |
ERR_STALE, ERR_FUTURE | 422 | The action is too old, or dated in the future |
ERR_TX_TIME | 503 | Transaction time and peer clock differ by more than 120 seconds |
ERR_REPLAY, ERR_EXISTS, ERR_STATE | 409 | Action ID already used, record already exists, or the transition is not allowed from the current status |
ERR_NOT_FOUND | 404 | A referenced record is missing |
ERR_GUARD | 422 | A domain rule failed; data.guard names it, for example NO_COGNIZABLE_SECTION |
ERR_PRIVATE_MISSING, ERR_PRIVATE_HASH, ERR_COMMIT_MISMATCH | 400 | The private payload is missing, does not match its hash, or does not match a public commitment |
ERR_ERASED | 410 | The private value was purged by a governed erasure |
ERR_CONFIG, ERR_PAGE, ERR_INTERNAL | 500, 400, 500 | Configuration problem, bad page bookmark, unexpected state |
Refused attempts are not written to the ledger, because a committed denial record would let any registered key write to the ledger at will. The backend's append-only audit log keeps every refused signed action instead.
Endorsement
Two layers of policy decide which institutions must agree to a write.
Chaincode-level policies, from the network's chaincode definitions:
| Chaincode | Policy |
|---|---|
identity-registry | OutOf(2, State A, State B, Judiciary, Forensics), so no single institution can create another's keys |
fir-registry | AND(Judiciary, OR(State A, State B)) |
evidence-custody | AND(Forensics, OR(State A, State B)) |
judicial-actions | AND(Judiciary, OR(State A, State B)) |
Key-level (state-based) policies, set by the chaincode when it creates a record:
| Records | Policy |
|---|---|
| An institution's own records and keys | That institution |
| Stations, districts and areas of a state | That state's police |
| Courts and link-court delegations | Judiciary |
| Laboratories | Forensics |
| A citizen's DID | The DID's state police and the judiciary |
| On-ledger configuration | A majority of all members (3 of 4 today) |
| A case and all its sub-records | The case's state police and the judiciary |
| An exhibit and its custody hops | Forensics and the exhibit's state police |
| Court actions and status requests | Judiciary and the case's state police |
The backend always names the endorsing organisations explicitly for each transaction. When a transaction carries private data, it goes to exactly those organisations and no others. A seizure memo, for example, is endorsed by the state and the judiciary only, so the forensic laboratories never receive it.
Creation anchors
Every creation also writes an anchor key with the owner's policy (ANCHOR~ORG, ANCHOR~DID, ANCHOR~<state> and others). This stops a dishonest endorser from leaving out the owner's agreement when creating a record.
Cross-chaincode calls
| Caller and callee | Kind | Functions |
|---|---|---|
The three case chaincodes to identity-registry | Read | ResolveSigner, ResolveCreator, GetConfig, GetStation, ResolveArea, CourtsForStation, GetCourt, GetLab, ListKeysByOfficial |
judicial-actions to fir-registry | Write | ApplyCourtOutcome, the only cross-chaincode write |
judicial-actions to fir-registry | Read | GetCase, GetReferral |
evidence-custody to fir-registry | Read | GetCase, FileHashRefs, PrivateKeyHash |
fir-registry to evidence-custody | Read | FileHashRefs, GetExhibit, VerifyReport |
fir-registry to judicial-actions | Read | GetStatusRequest, OpenStatusRequests |
ApplyCourtOutcome refuses to run unless the transaction's top-level chaincode is judicial-actions, and it checks the magistrate's signature again itself.
Limits
| Item | Limit |
|---|---|
| Action text | 64 KiB |
| Private payload | 1 MiB, or 16 MiB when it carries copies for a handover |
| Narrative, note or reason text | 100 000 UTF-8 bytes |
| Court reasons | At least 20 characters, at most 16 000 bytes |
| Attachments | 10 per action, 50 per case, each up to 50 MiB |
| Evidence media (seizure videos, statement media) | Up to 2 GiB each |
| Read targets per batch | 50 |
| Arrest events per window | 200, after which the window is sealed into a hash-chained segment |
Deployment and upgrades
make build compiles each chaincode offline in golang:1.27.3-alpine from the vendored modules, and make images builds the images. A new release is a new chaincode definition, approved under the channel's lifecycle policy (the judiciary and two of the other three organisations). The specification fixes the order of upgrades: a release that adds fields to identity-registry records is committed for the three case chaincodes first. Once a record holding a new field has been written, a release can only be rolled forward, because older code refuses fields it does not know.
Sources: digifir-chaincode README.md, docs/SPEC.md sections 1.6 to 1.15 and 7; digifir-backend docs/architecture/DECISIONS.md ADR-05, ADR-06 and ADR-18.