Tutorial: Building an Auditable Ledger with Tamper-Proof DocTypes
In this tutorial, you will create an append-only, mathematically auditable ledger in Framework M. You will learn how to declare a tamper-proof DocType, persist chained records, test that mutations are blocked at both the Python and SQL layers, and verify cryptographic continuity using the CLI.
Prerequisites
- Framework M installed in your virtual environment (
libs/framework-m-coreandlibs/framework-m-standard). - Database configured (SQLite or PostgreSQL).
Step 1: Declare the Tamper-Proof DocType
Create a model for recording financial balance adjustments. Inherit from TamperProofMixin and enable tamper_proof = True in Meta:
from pydantic import Field
from framework_m_core.domain.base_doctype import BaseDocType
from framework_m_core.domain.mixins import TamperProofMixin
class AccountAdjustment(TamperProofMixin, BaseDocType):
"""Immutable ledger record representing balance adjustments."""
account_id: str = Field(description="Target account")
amount_cents: int = Field(description="Adjustment amount in cents")
reason: str = Field(description="Audit reason")
stream_id: str = Field(default="primary", description="Stream partition")
class Meta:
table_name = "account_adjustments"
tamper_proof = True
TamperProofMixin provides two core fields:
prev_hash: str | None: SHA-256 digest of the preceding record in thisstream_id.hash: str | None: SHA-256 digest of(prev_hash + canonical_json).
Step 2: Synchronize the Database Schema
Run schema synchronization to generate the table, hash indexes, and native database triggers:
m db sync-schema
This generates:
- Columns
prev_hash(VARCHAR(64)) andhash(VARCHAR(64)). - Index on
(stream_id, hash). - Native SQL triggers (
prevent_{table_name}_mutation()in PostgreSQL or abort triggers in SQLite) that reject direct SQLUPDATEandDELETEcommands.
Step 3: Insert Records via GenericRepository
Persist ledger records using GenericRepository. The repository's TamperManager automatically computes the JCS RFC 8785 canonical hash chain:
from framework_m_standard.adapters.db.generic_repository import GenericRepository
repo = GenericRepository(model=AccountAdjustment, table=table)
async with session_factory.get_session() as session:
# First record in the stream (prev_hash will be None)
first_record = await repo.save(
session,
AccountAdjustment(
account_id="ACC-100",
amount_cents=5000,
reason="Opening balance",
stream_id="primary",
),
)
await session.commit()
print(f"Record 1 hash: {first_record.hash}")
# Second record in the stream (prev_hash will equal first_record.hash)
second_record = await repo.save(
session,
AccountAdjustment(
account_id="ACC-100",
amount_cents=-1200,
reason="Service fee",
stream_id="primary",
),
)
await session.commit()
print(f"Record 2 prev_hash: {second_record.prev_hash}")
assert second_record.prev_hash == first_record.hash
Step 4: Verify Mutation Defense
1. Application-Layer Guard
Attempting to update an existing record via the repository raises TamperProofMutationError:
from framework_m_standard.adapters.db.tamper_manager import TamperProofMutationError
try:
first_record.amount_cents = 99999
await repo.save(session, first_record)
except TamperProofMutationError as e:
print(f"Blocked by application: {e}")
2. SQL Trigger Guard
Attempting to mutate records directly via SQL aborts at the database engine level:
UPDATE account_adjustments SET amount_cents = 99999 WHERE id = '...';
-- PostgreSQL Error: Table "account_adjustments" is tamper-proof: UPDATE and DELETE operations are strictly prohibited.
Step 5: Audit Chain Continuity via CLI
Verify that the stream has not been tampered with:
m audit verify-chain --doctype AccountAdjustment --stream primary
Output:
✓ Stream 'primary' for DocType 'AccountAdjustment' is authentic (2 records scanned, zero breaks).
If a rogue admin drops triggers and alters a row, running m audit forensic-trace pinpoints the exact document:
m audit forensic-trace --doctype AccountAdjustment