This release is a pre-release and may not be stable for production use.
SignLedger
A tamper-evident, append-only audit log for Python.
SignLedger answers one question: has this log been altered since it was written?
It does not claim to answer that unconditionally, because no library can. How much the answer is worth depends on how you deploy it — so SignLedger computes that and tells you, instead of asserting it in prose.
pip install signledger
If you are on 1.0.0, upgrade. 1.0.0 is withdrawn: it did not provide the tamper-evidence it documented. On SQLite and PostgreSQL — the documented default — it built no hash chain at all, and its signatures were never verified. SECURITY.md explains what to assume about a log written by it, and why a 1.0.0 ledger cannot be migrated with its integrity intact — it never had any.
Quick start
from signledger import Ledger
with Ledger() as ledger:
ledger.append({"actor": "alice", "action": "deleted-record", "record_id": 42})
ledger.append({"actor": "bob", "action": "changed-role", "target": "carol"})
report = ledger.check_integrity()
print(report.ok) # True
print(report.entries_verified) # 2
print(report.assurance.value) # chain_only
Ledger() with no arguments keeps entries in memory, which is useful for a test and
useless for an audit trail. Point it at a store to keep anything:
import os, tempfile
from signledger import Ledger
from signledger.backends import SQLiteBackend
path = os.path.join(tempfile.mkdtemp(), "audit.db")
with Ledger(SQLiteBackend(path)) as ledger:
entry = ledger.append({"actor": "alice", "action": "login"})
print(entry.sequence) # 0
Read this before relying on it
A hash chain on its own is not tamper evidence. SHA-256 is a public function, so anyone who can write to your database can edit a row and recompute every hash after it. The chain verifies perfectly afterwards.
Only two things actually stop that — a secret the attacker cannot forge, or a record kept somewhere they cannot reach. SignLedger reports which of them you have:
| Level | What you have | What it detects |
|---|---|---|
none |
chain broken, or nothing verified | — |
chain_only |
hash chain only | accidental corruption, and naive edits |
signed |
every entry signed with a key the attacker does not hold | a database insider rewriting rows |
anchored |
signed, plus a head published outside the database | deletion of the tail, which nothing else can see |
worm |
anchored, plus a store that refuses UPDATE/DELETE |
rewriting at all |
from signledger import AssuranceLevel, Ledger
with Ledger() as ledger:
ledger.append({"event": "something happened"})
level = ledger.assurance_level()
assert level is AssuranceLevel.CHAIN_ONLY
If assurance_level() returns chain_only, someone with write access to your database
can rewrite your audit log undetectably. That is not a bug — it is what a bare chain is.
The rest of this README is about getting above it.
Signing
A signature is a secret the attacker cannot forge. The key's identity is part of the hashed preimage, so it cannot be swapped without breaking the chain.
from signledger import AssuranceLevel, Ledger
from signledger.crypto.signatures import Ed25519Signer
signer = Ed25519Signer.generate()
with Ledger(enable_signatures=True, signer=signer) as ledger:
ledger.append({"actor": "alice", "action": "wired-funds", "amount": "10000.00"})
level = ledger.assurance_level()
assert level is AssuranceLevel.SIGNED
Signing needs the optional extra:
pip install 'signledger[signatures]'
Ledger(enable_signatures=True) without a signer fails at construction, not on the first
append.
Anchoring
Signatures do not detect deletion. Remove the last 200 entries and what remains is a shorter log that is internally flawless — every hash and every signature still checks out. Nothing inside the database can tell you those entries ever existed.
An anchor is a record of the chain head kept where the attacker does not control it.
import os, tempfile
from signledger import Ledger
from signledger.anchor import FileAnchorSink
from signledger.backends import SQLiteBackend
directory = tempfile.mkdtemp()
store = os.path.join(directory, "audit.db")
anchors = os.path.join(directory, "anchors.jsonl")
with Ledger(SQLiteBackend(store), anchor_sink=FileAnchorSink(anchors)) as ledger:
for index in range(5):
ledger.append({"event": "payment", "index": index})
checkpoint = ledger.checkpoint()
print(checkpoint.entry_count) # 5
Take a checkpoint periodically and keep it somewhere else — another host, an append-only
bucket, a transparency log. check_integrity() compares the head against the newest
anchored checkpoint, and a truncated tail then shows up as HEAD_MISMATCH and
COUNT_MISMATCH.
Verification
check_integrity() never raises because the log is damaged; it returns a report. It
collects every failure rather than stopping at the first, because someone looking at a
corrupted audit log needs the whole picture.
from signledger import Ledger
from signledger.backends import MemoryBackend
backend = MemoryBackend()
with Ledger(backend) as ledger:
ledger.append({"event": "one"})
ledger.append({"event": "two"})
# Rewrite a stored row behind the ledger's back, the way an insider would.
stored = backend._entries[0]
backend._entries[0] = stored.model_copy(update={"data": {"event": "tampered"}})
report = ledger.check_integrity()
print(report.ok) # False
print(sorted({failure.kind.value for failure in report.failures}))
Failures name what broke and where: broken_link, bad_hash, bad_signature,
missing_signature, sequence_gap, genesis_mismatch, head_mismatch,
count_mismatch, unknown_key, format_unsupported.
A checkpoint gets its own six, because a checkpoint is the only thing that sees a truncated
tail and is therefore the next thing an attacker goes for — collapsing them into
head_mismatch would say the chain is wrong when what is wrong is the record being compared
against it: checkpoint_unsigned, checkpoint_unknown_key, checkpoint_bad_signature,
checkpoint_ledger_mismatch, checkpoint_format_unsupported, checkpoint_merkle_mismatch.
An empty range is reported as not ok. A verifier that returns success having checked nothing is worse than no verifier.
Storage backends
from signledger.backends import MemoryBackend, SQLiteBackend
memory = MemoryBackend() # tests only; nothing survives the process
sqlite = SQLiteBackend(":memory:") # or a path, for WAL + synchronous=FULL on disk
memory.close()
sqlite.close()
PostgreSQL and MongoDB need their own extras, and are imported by path so a base install never needs their drivers:
pip install 'signledger[postgresql]' # signledger.backends.postgresql.PostgresBackend
pip install 'signledger[mongodb]' # signledger.backends.mongodb.MongoBackend
Every backend runs the same conformance suite, so they behave identically. Ask one what it actually guarantees rather than assuming:
from signledger.backends import SQLiteBackend
backend = SQLiteBackend(":memory:")
print(sorted(backend.capabilities()))
backend.close()
An append is one database transaction — the tip read and the insert together — plus
UNIQUE constraints on sequence and previous_hash, and a bounded retry on conflict.
Two workers cannot fork the chain.
Batching
from signledger import Ledger
from signledger.core.batch import BatchItem, BatchProcessor
with Ledger() as ledger:
result = BatchProcessor(ledger).append_batch([
{"event": "one"},
{"event": "two"},
BatchItem(data={"event": "three"}, metadata={"source": "importer"}),
])
print(result.ok, result.committed) # True 3
Everything a caller can get wrong — an unencodable payload, a naive timestamp, a repeated
entry_id — is checked before the first write, so with atomic=True (the default) one
bad item means nothing at all is written.
Honest number: batching is roughly 1.1–1.2x faster than a loop of append(), not an
order of magnitude. Most of the cost is canonical encoding, which both paths pay.
What goes in an entry
Payloads must be JSON-native: dict, list, str, int, bool, None, float.
Decimal, datetime, bytes, set and tuple are rejected, with the JSON path in
the message:
from decimal import Decimal
from signledger import Ledger
from signledger.core.exceptions import CanonicalizationError
with Ledger() as ledger:
try:
ledger.append({"amount": Decimal("1.50")})
except CanonicalizationError as err:
print(err) # data.amount: Decimal is not JSON-native; convert to str
That strictness is deliberate: silently coercing values is how two different payloads end
up with one hash. Convert to str and keep the exactness.
Error messages never contain your payload, your connection string or your credentials. They carry field names, JSON paths, sequence numbers and type names only — an audit library's exceptions end up in logs its payloads were deliberately kept out of.
Every error derives from SignLedgerError:
from signledger import Ledger, SignLedgerError
with Ledger() as ledger:
try:
ledger.append({}) # data must not be empty
except SignLedgerError as err:
print(type(err).__name__) # ValidationError
Command line
signledger verify -b sqlite:audit.db
signledger stats -b sqlite:audit.db
signledger inspect -b sqlite:audit.db --forks
signledger checkpoint -b sqlite:audit.db --anchor anchors.jsonl
signledger export -b sqlite:audit.db -o bundle.json
signledger verify-bundle bundle.json
Commands that make an integrity claim refuse to run on a ledger that does not verify: publishing a checkpoint over a rewritten chain would launder the damage into the one record an auditor is meant to trust.
Exit codes
The contract with signledger verify || alert. The distinction between 1 and 2 is the
whole point — an operator must be able to tell "the log was tampered with" from "I typed
the DSN wrong", and until 2.0.0 they could not.
| Code | Meaning |
|---|---|
0 |
it verified |
1 |
an integrity verdict: tampering, truncation, a broken link — or an empty store, because a verifier that reports success having checked nothing is worse than no verifier |
2 |
operational: the store does not exist, a bad URI, a missing driver, an unreadable key |
A store that does not exist is 2, never 1. It used to be answered by provisioning an
empty ledger and reporting that it held nothing — telling an operator their audit log was
gone when they had mistyped a name.
Reading a store you must not write to
Every read-only command (verify, stats, inspect, export, anchor --show) opens the
store with create=False, which provisions nothing at all: no tables, no triggers, no
indexes, and on SQLite not even PRAGMA journal_mode = WAL, which is itself a write to the
database header. So an integrity check can run as a read-only database role, against a hot
standby, or against a copy on read-only media — which is what an auditor is usually handed.
The same argument is available directly:
import os, tempfile
from signledger import Ledger
from signledger.backends import SQLiteBackend
path = os.path.join(tempfile.mkdtemp(), "audit.db")
with Ledger(SQLiteBackend(path)) as writer:
writer.append({"event": "recorded"})
reader = SQLiteBackend(path, create=False)
with Ledger(reader) as auditor:
print(auditor.check_integrity().ok) # True
A create=False backend refuses append(), and refuses to open a store that is not there
rather than bringing one into being. memory:// has nothing to read, so it is refused
outright.
Two honest caveats, because "reads nothing" is a stronger claim than SQLite lets anyone make:
- The directory may still gain a
-waland-shm. Opening a WAL database read-only in a writable directory lets SQLite build its own wal-index beside the file. That is SQLite's doing, not the library's; the database file is byte-identical afterwards and no stored row changes. On genuinely read-only media nothing is created at all. - A store whose
-walcannot be read is refused, not guessed at. If a snapshot arrives with uncheckpointed commits still in its write-ahead log and the log cannot be replayed, reading the.dbalone would return a valid-looking prefix of the chain — it hashes, it links, it verifies — while silently omitting everything the log still held.signledgerstops instead, and tells you to checkpoint at the source or copy the store somewhere writable.
If you point the CLI at a non-default table, name both — --entries-table alone now
fails rather than silently provisioning a default-named ledger_checkpoints beside it and
reporting success.
Web framework integration
pip install 'signledger[flask]' # signledger.contrib.flask
pip install 'signledger[fastapi]' # signledger.contrib.fastapi
pip install 'signledger[django]' # signledger.contrib.django
One rule applies to all three: build the Ledger inside your application factory, not at
module scope. A pre-forking server imports your module once and then forks, so a ledger
built at import time hands every worker the same database handle. On PostgreSQL that
corrupts the wire protocol between processes. Build it per worker, after the fork, and pass
the same ledger_id to each — see Deployment.
The three integrations record the same thing
An auditor's query must not depend on which framework served the request. That is checked
rather than asserted: tests/test_contrib_parity.py drives the same scenario matrix against
all three over real sockets — werkzeug for Flask, uvicorn for FastAPI, a real WSGI
server for Django — and compares what comes out.
| Scenario | Flask | FastAPI | Django |
|---|---|---|---|
200, 404, 405 |
1 entry, completion="final" |
same | same |
view raises → 500 |
1 entry, error names the exception |
same | same |
| streamed body, completes | provisional + settlement outcome="complete" |
same | same |
| streamed body, raises mid-way | provisional + settlement outcome="incomplete" + error |
same | same |
HEAD on a streaming route |
1 entry, final |
same | same |
204 |
1 entry, final |
same | same |
It asserts entry counts, kind and its order, that completion is never absent, whether
error is present and what it names, the full key set of both entry kinds, request_id
correlation, absence of orphan settlements and of unsettled promises, and chain integrity —
per scenario, per framework.
Where the three legitimately differ it says so by name rather than eliding it. Request
body capture: FastAPI's ASGI middleware buffers only what the endpoint actually reads off
receive, so a request answered before anything reads the body records
body: "[OMITTED:not-read]" where Flask and Django hold the redacted body. The suite pins
this on the 405 case; the same applies to anything else that answers without reading —
an unrouted path, an auth dependency rejecting the request. It is deliberate: draining the
stream inside the middleware would change ASGI semantics and can hang against a client that
sends no body.
Two behaviours are shared by all three and worth knowing:
skip_pathsmatches whole path segments.skip_paths=("/health",)covers/healthand/health/liveand not/healthz-admin-secret. A bare prefix match once left a real route serving real data with no entry at all.- The audit console is never part of the trail it serves. Registering the Flask
blueprint or the FastAPI router excludes its own mount automatically, including the
unauthenticated
401an anonymous prober gets — otherwise every read of the ledger was a write to it, and/verifygrew more expensive each time it was called.
Flask
from flask import Flask
from signledger import Ledger
from signledger.backends import SQLiteBackend
from signledger.contrib.flask import SignLedgerAudit, audit_blueprint
from signledger.contrib.redaction import RedactionPolicy
def create_app():
app = Flask(__name__)
ledger = Ledger(SQLiteBackend("audit.db"), ledger_id="billing-api")
SignLedgerAudit(
app,
ledger=ledger,
redaction=RedactionPolicy(capture_body=True),
audit_failure_policy="fail_closed",
skip_paths=("/health",),
user_resolver=lambda request: getattr(request, "actor", None),
)
return app
audit_failure_policy is the decision that matters. fail_closed (the default) means a
request whose audit entry cannot be written fails — the client gets a 500 and nothing
happens unrecorded. fail_open serves the request anyway and drops the record. For a
compliance log the first is almost always right; choosing the second should be a decision
someone wrote down.
skip_paths keeps health checks out of the ledger. A load balancer polling /health twice
a second will otherwise bury every real event.
audit_blueprint(ledger, authenticator=...) mounts a read-only console at /_audit/status,
/_audit/entries and /_audit/verify. It exposes hashes, sequences and timestamps — never
payloads. There is deliberately no default authenticator: it is your audit log, and the
library will not guess who may read it.
FastAPI
from fastapi import FastAPI, Request
from signledger import Ledger
from signledger.backends import SQLiteBackend
from signledger.contrib.fastapi import SignLedgerAuditMiddleware, audit_router
from signledger.contrib.redaction import RedactionPolicy
def create_app():
ledger = Ledger(SQLiteBackend("audit.db"), ledger_id="payments-api")
app = FastAPI()
app.add_middleware(
SignLedgerAuditMiddleware,
ledger=ledger,
redaction=RedactionPolicy(capture_body=True),
skip_paths=("/health",),
user_resolver=lambda request: request.headers.get("x-actor"),
)
app.include_router(
audit_router(
ledger,
authenticator=lambda request: request.headers.get("x-audit-key") == "…",
)
)
return app
audit_router(ledger, authenticator=...) mounts the same read-only console as the Flask
blueprint, at /_audit/status, /_audit/entries and /_audit/verify. The requests it
answers are not audited, so reading the trail is not a write to it — and that includes
the 401 an unauthenticated caller gets, the 422 from a malformed limit, and the 404
for any unrouted path under its mount, which is the cheapest probe on the application and
the only one needing no credential at all. The exclusion is worked out per request from the
path the router actually answers on, so it holds under a custom prefix, under
include_router(..., prefix=...), under app.mount("/api", subapp), under
FastAPI(root_path="/gw"), and for a router included after the server started. There is no
skip_paths entry to remember, and writing one by hand would be wrong in all five of those
shapes.
A route of your own that merely shares the prefix — /_audit/dashboard, or a single-page
catch-all — is still audited: the exclusion covers the router's own endpoints, not every
path that starts with the same characters. An empty prefix is refused outright, because a
console at the root cannot be told apart from the site it reports on.
The audited decorator records a business event rather than an HTTP request, and composes
with the middleware rather than replacing it:
from fastapi import APIRouter
from signledger import Ledger
from signledger.contrib.fastapi import audited
ledger = Ledger()
router = APIRouter()
@router.post("/transfer")
@audited(lambda: ledger, action="funds.transfer")
async def transfer(amount: str) -> dict:
return {"transferred": amount}
ledger.close()
A route carrying both writes two entries — one describing the request, one describing the
action. That is intentional, but know that it is happening. Pass a callable (as above) rather
than a Ledger instance so the ledger is resolved per request, which is what lets the
decorator work with a per-worker ledger.
Streaming responses are audited when the status line is sent, because a fail-closed entry has
to be written before the first byte leaves. At that moment the status is a promise, not a
fact — the generator may still raise on its third chunk — so the entry says so with
completion="provisional", and a second entry (kind="response.completion", same
request_id) records how it actually ended: outcome="complete", or outcome="incomplete"
plus error naming the exception if one was raised.
All three integrations behave identically here, which is what
tests/test_contrib_shared.py exists to keep true. So an auditor counting successful
responses wants completion="final" plus the provisional entries that settled complete —
status: 200 on a provisional entry means 200 was sent, not 200 was delivered.
A response whose body is already built is completion="final" and gets no second entry. So
is one whose body a server will never send (HEAD, 204, 304), and so is a file response
sent under a declared Content-Length, because a receiver can detect a truncated one itself
as a short read.
Django
# settings.py
INSTALLED_APPS = [
...,
"signledger.contrib.django",
]
MIDDLEWARE = [
...,
"signledger.contrib.django.middleware.SignLedgerAuditMiddleware",
]
SIGNLEDGER = {
"LEDGER_FACTORY": "myapp.audit.build_ledger", # required, no default
"USER_RESOLVER": "myapp.audit.acting_user", # optional
"CAPTURE_BODY": True,
"AUDIT_FAILURE_POLICY": "fail_closed",
"TRUSTED_PROXY_COUNT": 0,
}
# myapp/audit.py
from signledger import Ledger
from signledger.backends import SQLiteBackend
def build_ledger():
return Ledger(SQLiteBackend("audit.db"), ledger_id="orders-service")
def acting_user(request):
user = getattr(request, "user", None)
if user is None or not user.is_authenticated:
return None
return user.get_username()
LEDGER_FACTORY is required and has no default. It and USER_RESOLVER both accept a
dotted path or a callable; a dotted path is usually better in settings.py, because a
callable forces the import at settings-import time.
Then run migrations — the app ships one, and you should never be asked to generate it:
python manage.py migrate
python manage.py check # fails if LEDGER_FACTORY is missing or unimportable
manage.py check validates the configuration at deploy time rather than letting the first
request discover it. Take it seriously in CI.
The app also creates an AuditIndex table. It is a queryable mirror, not the evidence —
the ledger is the evidence. It exists so you can find a record with the ORM or the Django
admin (a read-only ModelAdmin ships) without walking the chain. An index write that fails
is logged and never fails the request; an entry that is in the ledger but missing from the
index is a stale mirror, not a broken audit trail.
If you repoint LEDGER_FACTORY at a fresh store while keeping the same ledger_id, the
index rows from the old store are stale and are replaced as new entries arrive.
The index carries kind, completion and request_id alongside the identifiers, so the
questions v2 added are answerable through the ORM and the admin without walking the chain:
AuditIndex.objects.filter(completion="provisional") # promises not yet settled
AuditIndex.objects.filter(request_id=rid) # a request and its settlement
ASGI is supported. Async views and StreamingHttpResponse with an async generator are
audited and settled exactly as their sync counterparts are — an async stream that raises
after its first chunk is recorded provisional and then settled incomplete with the
exception named, not left claiming a completed 200. The middleware declares neither
sync_capable nor async_capable, so Django wraps it with
sync_to_async(thread_sensitive=True); that was measured not to serialise anything (four
concurrent async views sleeping 0.3s finish in 0.31s), and a second async implementation
would be a second place for the audit semantics to drift.
One deliberate exception: FileResponse is recorded final and not settled. Wrapping
it would set file_to_stream to None and take every download off the sendfile
zero-copy path, and where wsgi.file_wrapper is used the server iterates the raw file and
never enters the wrapper — so a fully delivered file would settle as incomplete. A body
sent under a declared Content-Length shows its own truncation to the client as a short
read, which is why Flask's send_file is treated the same way.
manage.py check also warns when SIGNLEDGER["MAX_BODY_BYTES"] and Django's
DATA_UPLOAD_MAX_MEMORY_SIZE are set so that a body would pass one and be refused by the
other.
What gets redacted, and what does not
Sensitive fields are replaced before anything is written — in the body, the headers and the query string, by one shared rule so the three can never disagree. Matching is by substring on the field name, and covers passwords, tokens, secrets, keys, one-time codes, crypto material, certificates and common PII identifiers.
from signledger.contrib.redaction import RedactionPolicy
policy = RedactionPolicy()
print(policy.payload({"user": "alice", "password": "hunter2"}))
Request bodies are not captured at all unless you ask. capture_body defaults to
False; headers and query strings are always redacted.
Three limits worth understanding before you turn body capture on, because this data goes somewhere nobody can edit it afterwards:
- A name denylist can never be complete. The rule was widened after fuzzing a generated
corpus of ~12,900 field names found whole categories it missed. Yours may use a name
nobody anticipated. Add them with
RedactionPolicy(extra_sensitive_fields=("customer_email",)). - Free text is never scanned. A field called
notescontaining "the customer said their password is hunter2" is stored verbatim. Redaction reads field names, not values. - It deliberately over-redacts. Short stems are matched as whole words, so
shipping,pinned,single,business,seededandconcertsurvive. Longer ones are matched as substrings, sokeyword,keyboard,author,discardandwildcardare blanked. For a store nobody can edit afterwards, that is the correct direction to be wrong in.
If a payload must never be stored even redacted, do not send it through the audited path.
Upgrading an existing ledger: the rule got wider in 2.0.0, and old entries are not
rewritten — they cannot be. A ledger that spans the upgrade holds the plaintext spelling of
a name like otp or account_number before the boundary and [REDACTED] after it,
permanently. If the earlier entries contain something that should not be there, the fix is
to retire that ledger and start a new one, not to edit it.
Deployment
The rules below are properties of fork() and of process identity, and they hold wherever
you deploy. The one number in this section comes from a job that runs on every push, on
GitHub's ubuntu-latest runner under CPython 3.12, so you can read it rather than take it
on trust. The by-hand runs it replaces were on macOS arm64 and are not reproduced here,
so treat any topology outside the table as one you have to measure yourself.
Every worker must be given the same ledger_id
Ledger() without ledger_id= mints one. That is fine for a single process and wrong
for a pre-forked server: identity then depends on which worker reached an empty store
first. Read one stable value from configuration and pass it in every worker.
import os, tempfile
from signledger import Ledger
from signledger.backends import SQLiteBackend
store = os.path.join(tempfile.mkdtemp(), "audit.db")
LEDGER_ID = "billing-api" # from config, identical in every worker
for worker in range(4): # what four workers each do, serialised here
with Ledger(SQLiteBackend(store), ledger_id=LEDGER_ID) as ledger:
ledger.append({"worker": worker, "event": "request"})
with Ledger(SQLiteBackend(store), ledger_id=LEDGER_ID) as ledger:
report = ledger.check_integrity()
print(report.ok, report.entries_verified) # True 4
Creating the ledger once and then starting the workers is the cheaper habit, and it is no longer required: four workers racing to create one store is the case the table below measures.
Open the database connection inside the worker, never before the fork
A PostgreSQL connection is not usable from more than one process. Under
gunicorn --preload a module-level backend is created in the master and inherited by
every child, which puts several processes on one socket.
That configuration used to fail outright, and the pool becoming fork-aware is what stopped
it. Follow the rule anyway. It is a property of fork() rather than of any pooling, so
it holds whatever a backend does internally, and nothing in CI re-measures --preload
against PostgreSQL — so the version that made it safe is one you would be trusting rather
than checking:
- with gunicorn, either drop
--preload, or build the backend in apost_forkhook; - uvicorn
--workersdoes not have the problem, because each worker imports the application itself instead of inheriting an open connection.
SQLite is unaffected — a file handle survives fork() in a way a socket does not — but the
rule costs nothing there either.
Verified clean
Chain verified means check_integrity() returned ok, sequences were contiguous from 0,
one ledger_id, and no duplicate sequence or previous_hash.
One topology is re-measured on every push, by the multi-worker cold start job in
.github/workflows/ci.yml.
It writes a Flask wsgi.py, starts gunicorn -w 4 against a SQLite file that does not
exist yet, drives 300 POSTs through a 32-thread pool, and fails the build unless every
column below holds:
| Server | Workers | Store | Requests | 2xx | Chain |
|---|---|---|---|---|---|
| gunicorn / Flask | -w 4 |
SQLite | 300 | 300 | 300 verified |
There is no seeded entry 0, which is why the chain is 300 long and not 301: four workers racing to create one store is the point of the job. It was a total outage before — a WAL-pragma race halted the master, and the three workers that lost the race to write entry 0 returned 500 for the life of the process.
Other shapes — --threads, -w 8, -w 12, uvicorn/FastAPI, Django, and PostgreSQL under a
pre-forking server — were exercised by hand while this release was built, and several
entries in CHANGELOG.md came
out of doing so. Their numbers are not printed here, because
nothing in this repository reproduces them and a measurement nobody can re-run is a claim
rather than evidence. This project has shipped enough of those. Treat any topology other
than the row above as untested until you have tested it.
Which store
SQLite is a real answer for multi-worker on one host: the row above is four separate processes writing one file, and the chain came out intact. It stops being an answer the moment a second host writes — a network filesystem does not give SQLite the locking it depends on.
PostgreSQL is the answer across hosts. Its concurrency is covered at the library level by the suite, against a live server rather than a mock; it is not covered under a pre-forking web server by anything you can re-run.
Compatibility
SemVer across three independent surfaces: the Python API, the on-disk schema, and the hash
format_version. Verification supports every historical format_version forever; writing
always uses the newest.
Limitations
Stated plainly, because a security library that hides these is worse than useless.
- Without an anchor, tail truncation is undetectable. Delete the last N entries and
what remains verifies perfectly. Use
checkpoint()and keep the result elsewhere. chain_onlydoes not survive a database insider. See the table above.- A process compromise beats signing. Anyone who can read the signing key can produce valid entries. Only external anchoring and an HSM/KMS signer help.
Entry.datais not deeply frozen. Mutating a returned entry'sdatadict in place changes what it hashes to.- Timestamps are limited to roughly 1684–2255, a consequence of encoding them as integer microseconds within the ±2^53 safe-integer bound.
- Merkle consistency proofs are one element longer than RFC 6962's when the old tree size is a power of two, because SignLedger binds the leaf count into the root.
- On SQLite and PostgreSQL,
anchoredis a damage signal, not a configuration. Both arm their append-only triggers when they create the entries table, so a healthy signed and anchored store reportsworm— the level above. Seeinganchoredon one of them means the triggers are gone, which is worth an alert rather than a shrug. MongoDB never reportsappend_only_enforced, soanchoredis its normal ceiling. - On Python 3.14,
fork()in a pre-forking server warns if the parent is multi-threaded — which it is wheneverauto_verify=Trueruns the background verifier. It is aDeprecationWarningfrom CPython, not from SignLedger, and it does not appear on 3.9–3.13. Building the ledger after the fork (which you should do anyway, see Deployment) avoids both the warning and the reason for it.
Development
python3 -m pytest --cov=signledger --cov-branch --cov-report=term-missing
PostgreSQL and MongoDB tests skip unless a live server is reachable; set
SIGNLEDGER_TEST_POSTGRES_DSN / SIGNLEDGER_TEST_MONGO_URI to point at your own. Nothing
in the suite mocks a database.
Every fenced python block in this file is executed by tests/test_docs_execute.py, and
every signledger line in a bash block is parsed and run there too — a renamed flag
breaks the suite instead of breaking the docs.
Coverage is gated at 100% line and branch — fail_under = 100 in pyproject.toml, over
line and branch alike — and that gate only means anything with both databases running:
653 of the 4,087 collected tests carry the postgres or mongo marker and skip
themselves when that server is not reachable. So CI runs both as services and fails if any
database test is skipped — a skipped test and a passing one look the same in the summary
line, which is exactly how a suite comes to be believed about code it never ran.
Both figures are counts from this tree rather than estimates:
python -m pytest --collect-only -q for the total, and the same command with
-m "postgres or mongo" for the database subset. How many of those 653 actually skip
depends on what is running on your machine; in CI the answer has to be none.
Security
Report vulnerabilities privately — see SECURITY.md. Release history and the full account of what was wrong with 1.0.0 are in CHANGELOG.md.
License
MIT
Release files for signledger 2.0b0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| signledger-2.0b0-py3-none-any.whl | Python 3 | none | any | Details |
Release files / signledger-2.0b0-py3-none-any.whl
| Download URL | signledger-2.0b0-py3-none-any.whl |
|---|---|
| Size | 213.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2f679135bc370e0ad0b6a013b4224735f6014ef42deeda3a67db365f4d240dfe
|
|
BLAKE2b-256 checksum How to use checksums |
305f5da49480223b60a0d82f98013661331ccc603650d8239f2c70458d22598b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.11.12
|