Skip to main content
All persistent state lives in a single SQLite file at <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

Two BEFORE 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_level outside {PRELIMINARY, ESTABLISHED} (REPLICATED can only be reached via UPDATE): mareforma:state:insert_invalid_level
  • support_level = 'ESTABLISHED' without a validation_signature (only the seed-claim path satisfies this, since seeds carry a signed seed envelope): mareforma:state:insert_established_without_validation
  • support_level = 'PRELIMINARY' with validated_by or validated_at set: mareforma:state:insert_preliminary_with_validation
claims_update_state_check: rejects:
  • PRELIMINARY → ESTABLISHED (must pass through REPLICATED first): mareforma:state:illegal_transition:from_preliminary
  • REPLICATED → PRELIMINARY: mareforma:state:illegal_transition:from_replicated
  • Any transition out of ESTABLISHED: mareforma:state:illegal_transition:from_established
  • → ESTABLISHED without a validation_signature: mareforma:state:established_without_validation
Every code is a static suffix, not a from/to pair: 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

Available at runtime via 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 call Graph.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 as replication_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. Only human-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 of claims (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 new claim_id and text
  • claims_fts_ad: AFTER DELETE, deletes the row’s index entry
  • claims_fts_au: AFTER UPDATE OF text, rewrites the indexed text. text is 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’s user_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: