<project_root>/.mareforma/graph.db
(WAL mode, ACID). Schema version: 1.
Claims table
Row-level CHECK: every row whose
support_level = 'ESTABLISHED' must have a non-NULL validation_signature. The CHECK is the row-level belt to the trigger’s transition-level suspenders.
Indexes
State-machine triggers
TwoBEFORE triggers enforce the support-level state machine at the storage layer. Defense in depth: a tampered Python interpreter cannot relax these rules.
claims_insert_state_check: rejects:
support_leveloutside{PRELIMINARY, ESTABLISHED}(REPLICATED can only be reached via UPDATE):mareforma:state:insert_invalid_levelsupport_level = 'ESTABLISHED'without avalidation_signature(only the seed-claim path satisfies this, since seeds carry a signed seed envelope):mareforma:state:insert_established_without_validationsupport_level = 'PRELIMINARY'withvalidated_byorvalidated_atset:mareforma:state:insert_preliminary_with_validation
claims_update_state_check: rejects:
PRELIMINARY → ESTABLISHED(must pass through REPLICATED first):mareforma:state:illegal_transition:from_preliminaryREPLICATED → PRELIMINARY:mareforma:state:illegal_transition:from_replicated- Any transition out of
ESTABLISHED:mareforma:state:illegal_transition:from_established → ESTABLISHEDwithout avalidation_signature:mareforma:state:established_without_validation
RAISE() cannot concatenate a column value below SQLite 3.46. Python translates any mareforma:state: code to IllegalStateTransitionError, carrying the suffix.
claims_update_status_terminal: retracted is terminal. Any UPDATE that transitions a row out of retracted raises mareforma:state:retracted_is_terminal. To resurrect a withdrawn finding, assert a new claim citing the old via contradicts=[<old_claim_id>].
claims_signed_fields_no_laundering: append-only over the signed predicate. It lives in _SIGNED_FIELDS_TRIGGER_SQL rather than _SCHEMA_SQL, so every open_db() drops and re-creates it and an existing database gains the current watch list. Refuses any direct-SQL UPDATE that changes a signed-predicate value (text, classification, generated_by, supports_json, contradicts_json, source_name, artifact_hash, ev_*, evidence_json, statement_cid, prev_hash, created_at), the asserter_keyid denormalisation, the observed_grounding verdict, or the predicate_payload the audit path re-checks a GROUNDED verdict against, on a row whose signature_bundle IS NOT NULL. Value-comparison fires only when something actually changed, so multi-column UPDATEs that re-emit unchanged values (e.g. status-only edits via update_claim) pass through. It also refuses to set signature_bundle back to NULL on a signed row, because that one write would clear the guard on this trigger and on claims_signed_no_delete; the Rekor attachment’s non-NULL rewrite stays legal. Raises mareforma:append_only:signed_field_locked.
claims_signed_promotion_backed: support_level is derived state, promoted after the signature that binds the claim’s content, so it cannot join the watch list above. What it is held to is the writer: on a row whose signature_bundle IS NOT NULL, the two transitions the state machine permits (PRELIMINARY → REPLICATED, REPLICATED → ESTABLISHED) are legal only inside a promotion window, which only the library’s promotion paths open. Anything else raises mareforma:append_only:promotion_unmarked. The marker is a temp table, so it is per connection: a co-resident process that opened graph.db with plain sqlite3 has no temp schema of its own to hold it and is refused. Trigger text is durable schema and every connection that opens the file has to be able to compile it, so the marker cannot be a name only one release puts on its connections. It is a speed bump, not the guarantee: the guarantee is on the read path, where a level above PRELIMINARY has to be backed by the signed evidence that earns it (a signature-verified replication verdict, or distinct-signer convergence on a shared ESTABLISHED anchor). A row that cannot show that evidence is served verified=False and dropped from query(min_support=...), exactly like a signature mismatch.
claims_signed_no_delete: the delete-side twin of claims_signed_fields_no_laundering. BEFORE DELETE on a row whose signature_bundle IS NOT NULL, raises mareforma:append_only:signed_claim_no_delete. A signed claim cannot be wiped from the local graph while its Rekor entry and chain hash persist. Unsigned claims (legacy / no-key mode) carry no cryptographic commitment and stay deletable.
contradiction_invalidates_older: AFTER INSERT on contradiction_verdicts. Sets claims.t_invalid = NEW.created_at on the older of the two referenced claims (lex-smaller claim_id as the deterministic tie-break when timestamps collide), idempotent via WHERE t_invalid IS NULL.
replication_verdicts_append_only + replication_verdicts_no_delete: UPDATE on the signed columns and any DELETE both raise mareforma:append_only:verdict_locked / verdict_delete_blocked. Same for contradiction_verdicts via the symmetric pair.
Valid values
mareforma.schema():
replication_verdicts table
Signed replication verdicts produced by enrolled validators. The OSS core accepts verdicts from any enrolled identity; the predicates that GENERATE verdicts (semantic-cluster, cross-method, hash-match, shared-resolved-upstream) live outside the OSS and callGraph.record_replication_verdict() to write here. Append-only at the SQL layer: UPDATE on signed columns and DELETE are both refused by triggers.
Side effect: recording a replication verdict promotes the referenced claims from
PRELIMINARY to REPLICATED (only if still PRELIMINARY AND status='open' AND t_invalid IS NULL). INSERT + promotion run in a single BEGIN IMMEDIATE transaction so a concurrent contradiction cannot land between the two writes.
contradiction_verdicts table
Signed contradiction verdicts. Same shape asreplication_verdicts but binds a refutation between two claims. INSERT fires the contradiction_invalidates_older trigger which sets t_invalid on the older referenced claim.
rekor_inclusions table
Sidecar recording every successful Sigstore-Rekor submission, written by_record_rekor_inclusion as step 3 of the Rekor saga. Step 4 (the claims-row UPDATE that attaches the Rekor coords to signature_bundle) reads from this table on retry instead of re-submitting, so a single Rekor submission produces exactly one log entry even when the local UPDATE crashes mid-saga.
Append-only at the SQL layer: both UPDATE and DELETE are refused by triggers, mirroring the verdict-table protections. The saga’s write uses INSERT ON CONFLICT(claim_id) DO NOTHING, so a legitimate retry on the same claim_id is a silent no-op (the original row is preserved) and a SQL-writer cannot launder forged Rekor coords through the recovery path in refresh_unsigned().
validators table
The per-project set of enrolled public keys. Onlyhuman-typed rows can promote claims to ESTABLISHED.
The first key opened against a fresh
graph.db auto-enrolls as the root with a self-signed envelope (BEGIN IMMEDIATE guards against two simultaneous opens both becoming roots). The chain walk enforces a singleton-root invariant: if two rows have keyid == enrolled_by_keyid, neither is trusted. Walk is capped at 64 hops.
Removal is intentionally unsupported currently; validator history is append-only.
project_policy table
A root-signed, single-row (id = 1) declaration of project-wide trust policy. It carries rekor_required (findings must be witnessed by the transparency log before they can converge, backing require_rekor_witnessing) and strict_promotion_required (a converging pair must carry data on both sides, backing open(strict_promotion=True)). Both are one-way once declared and bind every writer, not just the handle that declared them. The signed envelope is the authority; the flat columns are a denormalized read cache. The envelope payload carries its own version, so a declaration signed before a flag existed keeps verifying under the field list it was signed with. Extending the policy re-signs the row, which moves created_at, so each flag also carries the time it was first declared and a check that grandfathers earlier claims reads that instead. Restore verifies the envelope against the enrolled root before enforcing.
supports_revision table
A single-row (id = 1) monotonic counter over the claim_supports cache, bumped by every claim insert and every supports-edge change. It lives in graph.db rather than in the cache file, so it commits in the same database as the row it describes. The cache stamps the revision it was built from; a mismatch means the cache missed a mutation (a crash between the two WAL commits, or a writer that did not maintain it) and the cache is rebuilt on the next open. The claim-count check alone cannot see an in-place supports edit, which moves no count. Additive: an existing graph gains the table and its row on the next open, and that open rebuilds the cache once.
claims_fts table
The FTS5 virtual table behind text search. It is independent ofclaims (not content=claims), so storage is the only price of the feature and the sync triggers stay readable. claim_id is UNINDEXED, stored for join-back but not tokenized; the unicode61 tokenizer folds diacritics (remove_diacritics 2), so “gene” matches “géné”.
Three triggers keep it in lockstep with claims, all AFTER the write, so an IntegrityError on the wrapping statement rolls back the claim row and the search index together:
claims_fts_ai: AFTER INSERT, inserts the newclaim_idandtextclaims_fts_ad: AFTER DELETE, deletes the row’s index entryclaims_fts_au: AFTER UPDATE OFtext, rewrites the indexed text.textis a signed field, so this fires only on the unsigned-edit path
Trust layer tables
The trust layer (see Findings) adds seven tables for structured findings. They are additive:CREATE TABLE IF NOT EXISTS, no
migration, user_version stays 1. A finding is an evidence tree
(finding → evidence_lines → contrasts → effect_estimates), anchored to a
content-addressed proposition and a pre-registered prediction, and attested
by an existing signed claim.
propositions table
The content-addressed unit of sameness.content_id (PK) is the answer hash;
frame_id is the question hash.
Indexes:
idx_prop_frame (frame_id), idx_prop_frame_dir (frame_id, direction).
predictions table
The pre-registered plan, bound to one proposition.plan_id (PK) is content-addressed over (content_id, prediction fields), so registering the same plan twice is a no-op.
A registered plan is append-only:
predictions_append_only (BEFORE UPDATE of every immutable column) and predictions_no_delete (BEFORE DELETE) raise mareforma:append_only:prediction_locked / prediction_delete_blocked, so the gap between registration and evidence is a real pre-registration guarantee.
plan_retirements table
A plan written by a release with a wider alpha bound can state a rule no gate can run, and the row above can be neither corrected nor removed.graph.retire_plan(plan_id, alpha=..., reason=...) records the way out: the plan, the plan that supersedes it (the same rule at an alpha the gate can run), and why. The read path then gates that plan’s evidence under the replacement, so the lines count again instead of dropping.
A retirement is append-only like the plan it retires:
plan_retirements_append_only (BEFORE UPDATE) and plan_retirements_no_delete (BEFORE DELETE) raise mareforma:append_only:plan_retirement_locked / plan_retirement_delete_blocked.
None of these columns is signed, so both the read path and restore re-derive the row from the attestation: the claim’s text renders plan, replacement and reason, a row its claim does not render resolves nothing on read and fails the restore with kind='claim_unverified'.
Resolution is reached only from a plan whose own rule cannot be run, but that premise is suppliable: rewriting a live plan’s alpha to a value no gate can discriminate at sends its lines into resolution, and a planted replacement at a stricter alpha re-gates a refutation to NEUTRAL, which is counted rather than skipped. That is why the attestation is checked on the read and not only on restore.
findings table
One attestation plus its computed bearing on a proposition under a plan.evidence_lines table
One line of evidence; a finding may carry several. Independence is counted by pairwise-distinct model, dataset, and signer: a corroborating line counts only when it stands on a distinct model/method as well as a distinct signer and dataset, so a same-model rerun is one line, not two.contrasts table
The comparison a line quantifies (control type only, for now).effect_estimates table
The estimate the gate reads. Minimal metafor-named field set.
The two trust axes are derived on read, not stored.
Status (per content_id,
the answer) is computed from the independent supporting / refuting line counts,
counted by pairwise-distinct model, dataset, and signer (status_policy@v4), so
improving the rule later is a new policy over the same data, not a migration.
question_status (per frame_id, the question: consistent / divided) is
derived alongside it from the same computation. Both are what a reader should
read trust off; the stored support_level ladder above is the deprecated legacy
axis.
Schema versioning
The schema version is stored in SQLite’suser_version pragma.
No in-place migrations in this release. Adding a column, index, or
trigger means updating the schema in place; existing development
databases get the schema-validation error and the operator deletes
graph.db (claims.toml is the restore artifact). Versioned
migrations become relevant only after a 1.0 release establishes a
stable schema with real users on it.
Storage
graph.db is stored at <project_root>/.mareforma/graph.db. Created automatically
on first mareforma.open(). The .mareforma/ directory is created if it does not exist.
claims.toml at the project root is a human-readable backup of all claims,
written after every mutation. It is not the source of truth (graph.db is)
but it survives graph.db deletion.
Runtime PRAGMAs
open_db() sets these connection-level PRAGMAs on every open: