Skip to main content

Defining and Using Tamper-Proof DocTypes

This guide shows how to mark a DocType as append-only and tamper-proof, how the framework enforces immutability, and how to verify record continuity.


1. When to Use Tamper-Proof DocTypes

Use tamper-proof DocTypes for models that represent permanent records that must never be altered or deleted after creation:

  • Financial transactions and ledger entries
  • Audit logs and access history
  • System event logs

2. Declaration

Inherit from TamperProofMixin and set tamper_proof = True in the inner Meta class:

from pydantic import Field
from framework_m_core.domain.base_doctype import BaseDocType
from framework_m_core.domain.mixins import TamperProofMixin


class AuditEntry(TamperProofMixin, BaseDocType):
"""Immutable audit entry."""

action: str = Field(description="Action name")
actor: str = Field(description="User or service identity")
stream_id: str = Field(default="default", description="Stream partition key")

class Meta:
table_name = "audit_entries"
tamper_proof = True

What TamperProofMixin Adds

  • prev_hash: str | None: Hex-encoded SHA-256 hash of the previous record in the stream partition.
  • hash: str | None: Hex-encoded SHA-256 hash of (prev_hash + canonical_json).

Adding CryptoSignMixin for Multi-Party Dual Signing

For DocTypes that require edge node attestation and operator dual-signing (e.g. POS invoices or bank teller vouchers):

from framework_m_core.domain.mixins import CryptoSignMixin, TamperProofMixin


class FiscalInvoice(TamperProofMixin, CryptoSignMixin, BaseDocType):
invoice_number: str
grand_total: int

class Meta:
table_name = "fiscal_invoices"
tamper_proof = True
dual_sign = True

CryptoSignMixin adds non-repudiation origin and sequencing fields:

  • node_id & node_signature: The signing node's identity and Ed25519 digital signature.
  • operator_id & operator_signature: The operator's WebAuthn / FIDO2 hardware signature (or shift delegation signature).
  • sequence_no: Gapless monotonic sequence number within the stream partition.
  • prev_outbox_hash: Cumulative outbox stream digest preventing transaction omission or reordering.

What tamper_proof = True Does

During schema synchronization (SyncSchema), SchemaMapper generates:

  1. Columns prev_hash (String(64)) and hash (String(64)).
  2. An index on (stream_id, hash).
  3. Database triggers preventing UPDATE and DELETE queries:
    • PostgreSQL: prevent_{table_name}_mutation() trigger function.
    • SQLite: trg_prevent_{table_name}_update and trg_prevent_{table_name}_delete triggers raising an abort.

3. Saving Records

Persist instances using GenericRepository:

from framework_m_standard.adapters.db.generic_repository import GenericRepository

repo = GenericRepository(model=AuditEntry, table=table)

entry1 = AuditEntry(action="LOGIN", actor="alice")
saved1 = await repo.save(session, entry1)

entry2 = AuditEntry(action="LOGOUT", actor="alice")
saved2 = await repo.save(session, entry2)

TamperManager coordinates the hash chaining:

  • saved1.prev_hash: Initialized to "0" * 64.
  • saved1.hash: Calculated via RFC 8785 JCS canonicalization over saved1 payload combined with prev_hash.
  • saved2.prev_hash: Set to saved1.hash.
  • saved2.hash: Calculated over saved2 payload combined with saved2.prev_hash.

4. How Mutations Are Prevented

At the Application Layer

If application code attempts to update or delete a saved instance:

saved1.action = "TAMPERED"
await repo.save(session, saved1)
# Raises: framework_m_core.exceptions.TamperProofMutationError
# Message: "Cannot update tamper_proof entity AuditEntry"
await repo.delete(session, saved1.id)
# Raises: framework_m_core.exceptions.TamperProofMutationError
# Message: "Cannot delete tamper_proof entity AuditEntry"

At the Database Layer

If an external script or administrator connects directly with SQL:

UPDATE audit_entries SET action = 'TAMPERED' WHERE id = '...';
-- PostgreSQL Output:
-- ERROR: TAMPER REJECTED: Table audit_entries is append-only and cryptographically sealed.
-- CONTEXT: PL/pgSQL function prevent_tamper_proof_mutation()

The transaction is aborted and rolled back.


5. Verifying Chain Continuity

Use the m audit CLI command to verify that no rows have been deleted, inserted out of sequence, or modified:

# Verify specific DocType across all streams
m audit verify-chain --doctype AuditEntry

# Verify specific stream partition
m audit verify-chain --doctype AuditEntry --stream default

Output when the chain is intact:

✓ AuditEntry (stream=all): 42 records verified. Chain INTACT.

If a row was modified directly while triggers were disabled:

✗ AuditEntry (stream=all): TAMPER DETECTED at record 'AUDI-0012'! Checked 12 records.