Skip to main content

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-core and libs/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 this stream_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:

  1. Columns prev_hash (VARCHAR(64)) and hash (VARCHAR(64)).
  2. Index on (stream_id, hash).
  3. Native SQL triggers (prevent_{table_name}_mutation() in PostgreSQL or abort triggers in SQLite) that reject direct SQL UPDATE and DELETE commands.

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