Format per entry: What the technology / pattern is, Why it was used here, When it applies, How it's implemented, Alternatives considered, Why not those, Pros, Cons.
What · Every chaincode write uses a flat composite key like DOC_<tenantId>_<documentId> or SIG_<tenantId>_<documentId>_<signerCertHash>. Before putState, the chaincode reads the key and rejects the call if the key already exists. This makes the ledger record immutable at the chaincode level, regardless of who calls it.
Why · Hyperledger Fabric's underlying ledger is appendOnly at the block level — but putState for an existing key normally OVERWRITES (and the history is reconstructable via getHistoryForKey). For a documentIntegrity product, allowing overwrite would mean a privileged user could rewrite a document hash and the latest read would return the wrong value. ExistenceCheck makes "you can write once" a chaincodeEnforced invariant.
When · recordDocumentHash, signDocument, recordMultiSigExport. Permission and transfer records use a different pattern (timestamped keys for appendOnly history) — see §2.
How ·
async recordDocumentHash(ctx, tenantId, documentId, sha256Hash, filename, sizeBytes, uploaderId, s3Key) {
const key = `DOC_${tenantId}_${documentId}`;
const existing = await ctx.stub.getState(key);
if (existing && existing.length > 0) {
throw new Error(`Document ${documentId} already recorded on ledger. Cannot overwrite.`);
}
// ... build record ...
await ctx.stub.putState(key, Buffer.from(JSON.stringify(record)));
return JSON.stringify(record);
}The key prefix (DOC_, SIG_, MSIG_, XFER_, PERMS_) lets CouchDB rich queries select records by type efficiently, and gives operators a clear mental model of what type each record is.
Alternatives
- Allow overwrite, rely on
getHistoryForKeyto reconstruct. - Use Fabric collections (PDCs) for everything.
- External anchoring — write hashes to a public chain like Bitcoin OPRETURN.
Why not those
- Allow overwrite — every read becomes "what's the current value?" which can lie about historical state. Verification logic would have to walk history, expensive and errorProne.
- PDC for everything — destroys the publicVerification property. NonOrg members would not be able to confirm hashes exist.
- External anchoring — adds dependency on a public chain (cost, latency, complexity); doesn't add cryptographic strength beyond what Fabric's endorsement already provides for permissioned use cases.
Pros · Single chaincode invariant covers all overwrite attempts · existence check is O(1) read · the prefix scheme doubles as a humanReadable type tag · CouchDB selectors leverage the prefix for efficient tenantScoped queries.
Cons · A document genuinely needing reRecording (e.g., after a cleanUp of a wronglyRecorded entry) requires explicit chaincode upgrade — not a oneOff admin call. Acceptable for a compliance product where "this can never happen again" is a feature.
What · PERMS_<tenantId>_<Date.now()> — every permission change creates a new, unique record. Reading "current permissions" is a CouchDB rich query sorted by timestamp desc with limit 1.
Why · Permission changes (who in this tenant can upload, sign, transfer) need to be auditable as a complete change log, not a single mutable record. A regulator asking "who had upload privileges in Q3 2025?" needs a historical answer, not a current snapshot.
When · setTenantRolePermissions(tenantId, permissionsJson) and getTenantRolePermissions(tenantId).
How ·
async setTenantRolePermissions(ctx, tenantId, permissionsJson) {
const key = `PERMS_${tenantId}_${Date.now()}`;
const record = {
docType: 'rolePermissions',
tenantId,
permissions: JSON.parse(permissionsJson),
timestamp: new Date().toISOString(),
txId: ctx.stub.getTxID()
};
await ctx.stub.putState(key, Buffer.from(JSON.stringify(record)));
return JSON.stringify(record);
}
async getTenantRolePermissions(ctx, tenantId) {
const query = {
selector: { docType: 'rolePermissions', tenantId },
sort: [{ timestamp: 'desc' }]
};
const iterator = await ctx.stub.getQueryResult(JSON.stringify(query));
// ... iterate and return all records ...
}Alternatives
- Single mutable record —
PERMS_<tenantId>overwritten on each change. - OffChain audit log only — Mongo holds the change history.
- Use Fabric history — leave the key fixed and
getHistoryForKeyfor the change log.
Why not those
- Single mutable record — loses the change history at the ledger level. Verification would require trusting an offChain log.
- OffChain only — defeats the whole tamperEvidence story.
getHistoryForKey— works in principle, but its iteration semantics return data + tx IDs without the originaltimestampfield as a sortable selector. CouchDB sort bytimestamp descis more flexible for the common "last permission state at time T" query.
Pros · Full change log is queryable and sortable · each change has its own tx ID for audit · Date.now() collision impossible within reasonable concurrency (millisecond resolution per peer).
Cons · Date.now() from chaincode is the peer's view of time, not consensusAgreed time — slight skew possible across peers. Mitigated by using the timestamp only for sort order, not as a uniqueness invariant. Storage scales linearly with permission changes (acceptable — permission changes are rare).
What · Hyperledger Fabric supports CouchDB as the state database. When enabled, chaincode can issue MongoDBStyle selector queries via ctx.stub.getQueryResult(JSON.stringify(query)) instead of just keyBased lookups.
Why · Without rich queries, every "list all signatures for document X" or "list all documents for tenant Y" needs an offChain indexer that subscribes to events and rebuilds an index. This adds a moving part (the indexer) that itself becomes a trust dependency. CouchDB rich queries let the chaincode answer these questions directly from authoritative state.
When · queryDocumentSignatures, queryDocumentsByTenant, getTenantRolePermissions, getTransferPrivate.
How ·
async queryDocumentSignatures(ctx, tenantId, documentId) {
const query = {
selector: { docType: 'documentSignature', tenantId, documentId }
};
const iterator = await ctx.stub.getQueryResult(JSON.stringify(query));
// ... iterate, parse JSON, accumulate ...
return JSON.stringify(results);
}The docType field on every record is the discriminator. CouchDB indexes (indexes/....json files in chaincode metadata) make the common queries O(log n).
Alternatives
- OffChain indexer subscribing to Fabric events.
- CompositeKey range queries via
getStateByPartialCompositeKey. - GoLevelDB state DB instead of CouchDB.
Why not those
- OffChain indexer — extra service to run, monitor, recover. Adds an indirection between user query and authoritative state.
- Range queries — work for prefix scans (
SIG_<tenant>_<doc>_*) but not for arbitrary selectors (e.g., "all signatures by signer X across all documents"). - GoLevelDB — the default, but doesn't support rich queries. The whole pattern unwinds.
Pros · Rich queries from authoritative state · no offChain indexer dependency · standard MongoDBIsh syntax familiar to most developers.
Cons · CouchDB is heavier than GoLevelDB (more memory, slower writes) · query performance depends on having the right CouchDB indexes installed via chaincode metadata · Fabric peers running CouchDB are generally lowerThroughput than GoLevelDB peers (acceptable for a document platform; not for highFrequency trading).
What · Fabric exposes the full transaction history of any key as an iterator. No additional chaincode logic required — every commit to a key is automatically part of its history.
Why · For the rare cases where a key's history is needed as forensic evidence (e.g., did this document hash ever change? Even though we enforce immutability, evidence we DID enforce it is itself useful). Fabric ships the feature; we just call it.
When · getDocumentHistory(tenantId, documentId). Used by the admin SPA's audit drillDown and the evidenceBundle generator.
How ·
async getDocumentHistory(ctx, tenantId, documentId) {
const key = `DOC_${tenantId}_${documentId}`;
const iterator = await ctx.stub.getHistoryForKey(key);
const history = [];
let result = await iterator.next();
while (!result.done) {
history.push({
txId: result.value.txId,
timestamp: result.value.timestamp,
isDelete: result.value.isDelete,
value: result.value.value && JSON.parse(result.value.value.toString())
});
result = await iterator.next();
}
await iterator.close();
return JSON.stringify(history);
}For records with the existenceCheck immutability invariant, getHistoryForKey should always return exactly one entry — a non1 result is an anomaly that the admin SPA flags.
Alternatives
- Roll our own history table —
DOC_HIST_<tenant>_<doc>_<n>keys per write. - OffChain eventSourcing — index
recordDocumentHashevents into a Postgres timeSeries. - Don't expose history — return only current state.
Why not those
- Roll our own — duplicates work Fabric already does, with worse correctness guarantees (we'd have to keep a counter, dedupe, etc.).
- OffChain eventSourcing — adds a dependency. The indexer becomes the source of truth for history, which is exactly what we wanted to avoid.
- Don't expose history — undermines the audit story.
Pros · Free with Fabric · cryptographically tied to block ordering · no extra chaincode logic.
Cons · getHistoryForKey returns ALL history including deletes; chaincode has to handle the iterator carefully. Performance degrades if a key's history has thousands of entries (irrelevant here — our invariant means most keys have one entry).
What · The product is built on Hyperledger Fabric, a permissioned blockchain platform with multiOrg consensus, channel isolation, and pluggable state databases.
Why · The use case is multiOrganization compliance, not public DeFi. Fabric is designed exactly for this: organizations form a consortium, jointly operate the network, and trust the network's endorsement guarantees rather than trusting each other directly. PublicChain alternatives (Ethereum, etc.) would expose every document hash to the public, even though the documents themselves are private.
When · Throughout. Chaincode is in JavaScript on fabricContractApi; client code (offChain backend) uses fabricGateway for transaction submission.
How · ChannelLevel endorsement policy: AND('Org1MSP.peer', 'Org2MSP.peer') (default). Every state change requires endorsement from at least one peer in each org. Raft consensus among 3+ orderers determines block ordering. CouchDB state DBs on peers enable rich queries.
Alternatives
- Ethereum / EVM L2 with private subgraph for queries.
- Permissioned Ethereum like Quorum.
- AWS QLDB (managed centralized appendOnly ledger).
- Roll our own signed Merkle log over Postgres.
Why not those
- Ethereum — public by default. Even on a private network, the EVM model lacks PDCs, so PII handling is harder. Higher perWrite cost.
- Quorum — EthereumCompatible permissioned chain. Plausible alternative; Fabric was chosen for richer PDC semantics and channel isolation (multiTenant, multiChannel deployments).
- QLDB — single AWS region, single trust party (AWS). Loses the multiOrg property entirely.
- Roll our own — would need to reDerive consensus, ordering, endorsement, keyStore, and identity. Months of work to get to "buggy version of Fabric".
Pros · MultiOrg consensus by default · PDCs for PII · channel isolation for multiTenant · CouchDB rich queries · mature tooling (peer, chaincode lifecycle, fabricCa).
Cons · Operationally heavy — running a Fabric network is a nonTrivial DevOps undertaking · learning curve for engineers used to publicChain models · upgrades are coordinated across orgs (good for governance, slow for emergencies).
What · Hyperledger Fabric collections are a feature where data is replicated only to peers in a specified set of orgs. The hash of the data IS in the public block (so its existence is provable), but the data itself is gossiped privately.
Why · Signature material (signer email, public key PEM, signature hex) and transfer participants (sender/recipient identity) are personally identifiable. Putting this on the main ledger would replicate it to every peer in every org forever. PDCs let us prove "this signature exists" publicly while keeping the contents to a subset of trusted parties.
When · Two PDCs defined in collectionsConfig.json:
signaturePrivateData—OR('Org1MSP.member', 'Org2MSP.member')policytransferPrivateData— same policy
Used by recordSignaturePrivate / getSignaturePrivate and recordTransferPrivate / getTransferPrivate chaincode methods.
How ·
async recordSignaturePrivate(ctx, tenantId, documentId, signerCertHash) {
const transientMap = ctx.stub.getTransient();
const privateDataJSON = transientMap.get('privateData');
if (!privateDataJSON || privateDataJSON.length === 0) {
throw new Error('privateData not found in transient map');
}
const privateData = JSON.parse(privateDataJSON.toString());
const key = `SIGPRIV_${tenantId}_${documentId}_${signerCertHash}`;
const record = {
docType: 'signaturePrivateData',
tenantId, documentId, signerCertHash,
signatureHex: privateData.signatureHex,
signerEmail: privateData.signerEmail,
publicKeyPEM: privateData.publicKeyPEM,
timestamp: new Date().toISOString(),
txId: ctx.stub.getTxID()
};
await ctx.stub.putPrivateData('signaturePrivateData', key, Buffer.from(JSON.stringify(record)));
return JSON.stringify({ key, txId: record.txId });
}The PII enters via getTransient() — a side channel that is NOT part of the main transaction body. The transaction proposal travels with the transient data only to endorsing peers; nonEndorsers never see it.
Alternatives
- Encrypt and store on main ledger.
- OffChain only — store PII in MongoDB, only the cert hash onChain.
- External attestation service like EAS.
Why not those
- Encrypt + onChain — anyone could extract the ciphertext from the public block and run offline analysis. Key rotation is hard.
- OffChain only — defeats the "signature exists on the ledger" property. PDCs preserve the onChain proof.
- EASStyle external — adds a dependency outside the Fabric network with its own trust assumptions.
Pros · PII never enters the main block payload · existence is publicly provable via PDC hash · access controlled by collection policy.
Cons · PDCs require operational discipline — peers added to the org need reReplication; peers leaving need data deletion (Fabric supports this via purge semantics, but it's a careful operation) · PDCs have lower throughput than main state.
What · Sensitive arguments to a chaincode method are passed via getTransient() instead of regular function arguments. The transient data travels in a separate channel that doesn't get persisted to the block.
Why · Regular function args are part of the transaction proposal payload. The proposal payload is propagated to all endorsing peers AND signed by the client. If PII were in the args, it would be in the block forever. Transient data is excluded from the block — only the resulting state mutation (which we route to a PDC) is persisted.
When · Every PDC write — recordSignaturePrivate, recordTransferPrivate. PDC reads are by key, no transient needed.
How · ClientSide (Node.js):
const tx = contract.createTransaction('recordSignaturePrivate');
tx.setTransient({
privateData: Buffer.from(JSON.stringify({
signatureHex, signerEmail, publicKeyPEM
}))
});
await tx.submit(tenantId, documentId, signerCertHash); // public args onlyChaincodeSide: ctx.stub.getTransient().get('privateData').
Alternatives
- Pass PII as regular args.
- Encrypt in args, decrypt in chaincode.
- TwoStep submit — a public chaincode call followed by a separate PDC write.
Why not those
- Regular args — PII in the public block forever. Defeats the privacy story.
- Encrypt in args — same problem as encrypting onChain (§6 alternatives).
- TwoStep submit — nonAtomic; an interrupted flow could record the public hash without the corresponding PDC entry.
Pros · Atomicity (one tx for both public + PDC writes) · clean separation of public vs private inputs · standard Fabric idiom that most operators recognize.
Cons · The transientData channel is part of the proposal, so it travels gRPCEncrypted to endorsers but is in memory at every endorser briefly. Acceptable risk vs the alternative of putting it onChain.
What · The actual file content lives in an S3 bucket with Object Lock retention. The chaincode stores only the file's SHA256 hash plus a reference (s3Key). The chain is the proof of integrity; S3 is the proof of preservation.
Why · Storing files on Fabric would explode storage costs and degrade peer throughput. S3 Object Lock in compliance mode gives "even root cannot delete" semantics for the retention window. Combined with the onChain hash, any postHoc alteration is detectable: reFetch from S3, reHash, compare to chain.
When · recordDocumentHash records (sha256Hash, s3Key, ...). OffChain verifyDocument() reFetches from S3, reComputes hash, compares.
How · Backend SHA256 hashes the upload stream as it travels to S3, then writes both Mongo and Fabric records. S3 Object Lock retention is set perTenant (varies by plan).
Alternatives
- Store the file on Fabric (in main state or a PDC).
- IPFS pinning service — contentAddressable storage.
- Encrypt + store on a public chain like Arweave.
Why not those
- On Fabric — a 10MB file × every peer's storage × every block would crush the network. Fabric is designed for state, not bulk content.
- IPFS pinning — contentAddressing exposes which CIDs exist publicly. Pinning service availability is its own risk.
- Arweave / public chain — publicChain storage is expensive per byte and exposes content to public indexers.
Pros · Cheap storage at scale · Object Lock prevents deletion · reHash verification is a oneAPICall operation · S3 lifecycle policies (transition to Glacier, etc.) work normally.
Cons · S3 is one source of truth; an AWS region failure makes the file temporarily unavailable (mitigated with crossRegion replication for highTier plans) · the hash chain is only as trustworthy as the SHA256 collision resistance (still solid, but not futureProof against quantum).
What · The offChain backend doesn't call recordDocumentHash directly — it calls an IAuditProvider interface, which has two implementations: BlockchainAuditProvider (calls Fabric) and SqlAuditProvider (writes to MongoDB only). If the Fabric provider fails, the upload still succeeds and the document is marked blockchainStatus: FAILED. A cron job picks up failed records and retries.
Why · Fabric peer downtime, network partitions, and consensus delays would otherwise block uploads — a poor UX for a multiTenant SaaS where the user shouldn't notice infrastructure problems. The pluggable provider lets the upload path stay live during partial outages, with the cron's eventualConsistency catching up the chain records.
When · Upload path. The provider interface returns success/failure synchronously; the chaincode call is retried by the cron job.
How ·
// OffChain pseudoCode (backend, not chaincode)
class BlockchainAuditProvider {
async recordHash(tenantId, doc) {
try {
await contract.submitTransaction('recordDocumentHash', /*...*/);
return { ok: true };
} catch (e) {
return { ok: false, error: e };
}
}
}
// In upload handler:
const result = await auditProvider.recordHash(tenantId, doc);
mongo.update({ _id: doc._id }, { blockchainStatus: result.ok ? 'CONFIRMED' : 'FAILED' });
// Separate cron (jobs/blockchainRetry.js):
// every 5 min, find { blockchainStatus: 'FAILED' } and retry with backoffAlternatives
- Fail uploads when Fabric is down.
- Use a queue (BullMQ / Kafka) for blockchain writes.
- Synchronous retry inside the upload handler.
Why not those
- Fail uploads — bad UX; users blame the platform for infrastructure they don't control.
- Queue — would work and is a reasonable alternative. The cron pattern was chosen for operational simplicity (no extra Redis/Kafka dependency). Could migrate to a queue if write latency becomes a concern.
- Synchronous retry — extends the upload latency unpredictably.
Pros · Upload latency unaffected by Fabric availability · cron retry handles transient failures · provider abstraction makes future migration to a queue trivial.
Cons · Eventual consistency window (typically minutes) where a "newly uploaded" document may not yet be on the ledger. The UI surfaces this state explicitly (blockchainStatus: PENDING).
What · Fabric channels enforce an endorsement policy per chaincode. The default is AND('Org1MSP.peer', 'Org2MSP.peer') — every state change must be endorsed by at least one peer from each org before it can be ordered into a block.
Why · SingleOrg consensus would let a colluding admin within one org commit fraudulent state changes. MultiOrg endorsement means at least one peer in another org must agree, which makes covert tampering require collusion across organizational boundaries — a much higher bar.
When · ChannelLevel config, applied to every chaincode invocation.
How · AND('Org1MSP.peer', 'Org2MSP.peer') as the channel's default endorsement. For highStakes operations (e.g., evidence bundle generation), a stricter policy can be defined perChaincodeMethod via lifecycle endorsement.
Alternatives
- SingleOrg endorsement.
- NOfM with weighted votes.
- OffChain notarization.
Why not those
- SingleOrg — collapses multiOrg property; the platform becomes "one trusted operator with extra steps".
- Weighted votes — possible but adds complexity. TwoOrg AND is simpler and sufficient.
- OffChain notarization — defeats the inFabric consensus guarantees.
Pros · Tampering requires crossOrg collusion · standard Fabric idiom · org composition can be expanded (add Org3, Org4) without code changes — only config.
Cons · Every transaction has higher latency (must collect endorsements from multiple orgs) · adding/removing orgs is a coordinated network operation.
What · The offChain verifyDocument(tenantId, documentId) reFetches the file from S3, reHashes it, looks up the Mongo record, queries Fabric for the chain record, and returns a structured result indicating whether all three sources agree.
Why · Tamper at any single source is detectable. S3 alone could be swapped (admin overrides Object Lock — possible during retention setup mistakes). Mongo alone is trivially rewriteable. Fabric alone is the strongest but requires a quorum to attack. CrossChecking all three turns "one source rewritten" from "undetectable" into "structured failure with whichSourceDisagrees".
When · Backend's /verify/:docId endpoint, called by the tenant SPA's verify button and by the evidenceBundle generator before producing court output.
How ·
async function verifyDocument(tenantId, docId) {
const mongoDoc = await Mongo.docs.findOne({ tenantId, _id: docId });
const s3Object = await S3.getObject({ Bucket, Key: mongoDoc.s3Key });
const s3Hash = sha256(s3Object.Body);
const fabricResultJson = await contract.evaluateTransaction(
'queryDocumentHash', tenantId, docId
);
const fabricRecord = JSON.parse(fabricResultJson);
return {
s3: { ok: s3Hash === mongoDoc.sha256, hash: s3Hash },
mongo: { ok: mongoDoc.sha256 === fabricRecord.sha256Hash, hash: mongoDoc.sha256 },
fabric: { ok: true, hash: fabricRecord.sha256Hash, txId: fabricRecord.txId, timestamp: fabricRecord.timestamp },
valid: s3Hash === mongoDoc.sha256 && mongoDoc.sha256 === fabricRecord.sha256Hash,
};
}Alternatives
- SingleSource verification — trust Fabric only.
- TwoSource — S3 + Fabric, skip Mongo.
- Periodic background verification instead of onDemand.
Why not those
- SingleSource — Fabric is strongest, but losing connectivity makes verify impossible. TripleSource has graceful degradation.
- TwoSource — Mongo is actually useful as the "operational" check; if Mongo and Fabric disagree, that's a bug in the upload pipeline that should fail loud.
- Periodic only — users want immediate verification when needed.
Pros · Surface specific failure modes (S3 vs Mongo vs Fabric) · graceful degradation if one source is unavailable · evidence bundle includes all three hashes for legal verification.
Cons · Three roundTrips per verify (S3 GET, Mongo find, Fabric query). Acceptable for an onDemand operation; cached for the SPA listing pages.
What · The offChain backend uses @hyperledger/fabric-gateway (the modern client SDK introduced in Fabric 2.4+) instead of the older fabricNetwork package. Connection profiles per org (TLS certs, endorser endpoints, orderer endpoints) live in the deployment vault.
Why · fabricGateway is the supported client going forward. It uses gRPC streams instead of the older fabricNetwork's perTx gRPC roundtrips, with significantly better throughput and a cleaner async/await API.
When · Every chaincode invocation from the backend goes through fabricGateway.
How ·
const { connect, signers } = require('@hyperledger/fabric-gateway');
const grpc = require('@grpc/grpc-js');
const client = new grpc.Client(peerEndpoint, tlsCredentials);
const gateway = connect({
client,
identity: { mspId, credentials: certPem },
signer: signers.newPrivateKeySigner(privateKey),
});
const network = gateway.getNetwork(channelName);
const contract = network.getContract('chainproof');
await contract.submitTransaction('recordDocumentHash', /*...*/);Alternatives
fabricNetwork(older SDK).- Direct gRPC to peer endorsement APIs.
- Fabric REST proxy (
fabricRestSample).
Why not those
- Older SDK — deprecated, being phased out by Hyperledger.
- Direct gRPC — possible but reinvents the gateway logic (endorsement collection, ordering, retries).
- REST proxy — adds another hop and a service to maintain.
Pros · Modern, supported · gRPC streaming improves throughput · cleaner API.
Cons · Newer SDK; less prior art, stackOverflow answers often reference the older API. ConnectionProfile management is perOrg and operationally nonTrivial.