Skip to main content

Formal Verification (TLA+)

A formal-methods treatment of the Trickipedia progression network: the data model as deployed, its relationships to the rest of the app, a TLA+ specification of the design, and the results of model checking it with TLC plus a conformance check of the live production data — which found (and led to the fix of) a real prerequisite cycle.

Overview​

Trickipedia is TrickBook's trick encyclopedia: 144 trick documents across seven categories (Skateboarding 47, Snowboarding 74, BMX, Surfing, Longboarding, Inline, Scooter), each with description, difficulty, steps, media, and — for skateboarding and snowboarding — a progression network that tells riders what to learn first, what to try next, and what's related.

Data model (as deployed)​

Each trick is a MongoDB document in the trickipedia collection:

{
_id: ObjectId,
name: "Kickflip",
url: "kickflip", // stable slug, page route /trickipedia/<category>/<url>
category: "Skateboarding", // one of 7 sport categories
difficulty: "Intermediate", // Beginner | Intermediate | Advanced
description, steps, images, videos, videoUrl, source,
tutorials: [ { title, canonicalUrl, availability: "active" | ..., featured, instructor } ],
progression: {
prerequisites: [ Edge ], // learn these first (ordering semantics!)
nextSteps: [ Edge ], // natural follow-ups
related: [ Edge ] // variations / companion skills (no ordering)
}
}

An Edge carries editorial provenance, not just a pointer:

{
trickId: ObjectId, // the other trick
reason: "Supplies the flip half of the trick.",
order: 0,
research: {
status: "draft" | "reviewed" | "published" | "rejected",
confidence: "low" | "medium" | "high",
evidence: [ { url, claim, checkedAt } ]
}
}

Relationships with other features​

RelationshipMechanism
Network APIGET /api/trickipedia/:id/network returns foundations (prerequisites), nextSteps, related. Only edges with research.status ∈ {reviewed, published} are exposed; the hydrate step silently drops edges whose trickId no longer resolves (deletions leave dangling references in place).
Trick pagesThe network UI (three rails: "Learn these first", "Try next", "Related variations") is designed but not yet rendered by the website or mobile trick pages — the endpoint is live, the clients don't call it yet. See the rollout plan.
Trick listsThe "Add to TrickList" button writes into the user's tricklists documents; completion is tracked per list. Deleting a Trickipedia document does not touch list entries.
CategoriesEdges only ever link tricks within the same category (a skateboarding trick never lists a snowboarding prerequisite).

The formal specification​

The essential correctness question for a learning network: can every visible progression actually be learned? If the prerequisite graph a user sees contains a cycle, the ordering it promises is impossible. Secondary properties: the API must only serve moderated edges, must never expose dangling references, cross-category edges must not exist, and a user's completed tricks must be a subset of their list.

The spec models the system with a GuardedWrites switch: FALSE is the system as deployed (edge writers — the migration scripts — perform no cycle check); TRUE is the corrected design in which a prerequisite write is refused if it would create a cycle.

------------------------- MODULE TrickipediaNetwork -------------------------
EXTENDS Naturals, FiniteSets

CONSTANTS
Tricks, \* model universe of trick documents
SnowTricks, \* subset of Tricks in the second category
GuardedWrites \* BOOLEAN: cycle guard on prerequisite writes?

ASSUME SnowTricks \subseteq Tricks
ASSUME GuardedWrites \in BOOLEAN

CatOf(t) == IF t \in SnowTricks THEN "snowboarding" ELSE "skateboarding"

Pairs == {p \in Tricks \X Tricks : p[1] # p[2]}

Statuses == {"none", "draft", "reviewed", "published", "rejected"}
ActiveStatuses == {"draft", "reviewed", "published"}
VisibleStatuses == {"reviewed", "published"}

VARIABLES
existing, \* SUBSET Tricks: documents currently in the collection
edges, \* [Pairs -> Statuses]: prerequisite edge p[1] REQUIRES p[2]
list \* one user's trick list: [tricks: SUBSET, completed: SUBSET]

vars == <<existing, edges, list>>

RECURSIVE TransitiveClosure(_)
TransitiveClosure(R) ==
LET Next == R \cup {p \in Pairs \cup {<<t, t>> : t \in Tricks} :
\E b \in Tricks : <<p[1], b>> \in R /\ <<b, p[2]>> \in R}
IN IF Next = R THEN R ELSE TransitiveClosure(Next)

HasCycle(R) == \E t \in Tricks : <<t, t>> \in TransitiveClosure(R)

ActiveRel == {p \in Pairs : edges[p] \in ActiveStatuses}

\* What the network endpoint serves: moderated edges whose endpoints both
\* still resolve (the hydrate step silently drops dangling references).
ApiRel == {p \in Pairs : edges[p] \in VisibleStatuses
/\ p[1] \in existing /\ p[2] \in existing}

Init ==
/\ existing = {}
/\ edges = [p \in Pairs |-> "none"]
/\ list = [tricks |-> {}, completed |-> {}]

AddTrick(t) ==
/\ t \notin existing
/\ existing' = existing \cup {t}
/\ UNCHANGED <<edges, list>>

\* Deleting a document does NOT clean up edges that point at it, and does
\* not touch user lists: both mirror the deployed system.
RemoveTrick(t) ==
/\ t \in existing
/\ existing' = existing \ {t}
/\ UNCHANGED <<edges, list>>

ProposeEdge(p) ==
/\ p[1] \in existing /\ p[2] \in existing
/\ CatOf(p[1]) = CatOf(p[2])
/\ edges[p] = "none"
/\ GuardedWrites => ~HasCycle(ActiveRel \cup {p})
/\ edges' = [edges EXCEPT ![p] = "draft"]
/\ UNCHANGED <<existing, list>>

ReviewEdge(p) ==
/\ edges[p] = "draft"
/\ edges' = [edges EXCEPT ![p] = "reviewed"]
/\ UNCHANGED <<existing, list>>

RejectEdge(p) ==
/\ edges[p] = "draft"
/\ edges' = [edges EXCEPT ![p] = "rejected"]
/\ UNCHANGED <<existing, list>>

PublishEdge(p) ==
/\ edges[p] = "reviewed"
/\ edges' = [edges EXCEPT ![p] = "published"]
/\ UNCHANGED <<existing, list>>

AddToList(t) ==
/\ t \in existing
/\ t \notin list.tricks
/\ list' = [list EXCEPT !.tricks = @ \cup {t}]
/\ UNCHANGED <<existing, edges>>

CompleteTrick(t) ==
/\ t \in list.tricks
/\ t \notin list.completed
/\ list' = [list EXCEPT !.completed = @ \cup {t}]
/\ UNCHANGED <<existing, edges>>

Next ==
\/ \E t \in Tricks : AddTrick(t) \/ RemoveTrick(t)
\/ AddToList(t) \/ CompleteTrick(t)
\/ \E p \in Pairs : ProposeEdge(p) \/ ReviewEdge(p)
\/ RejectEdge(p) \/ PublishEdge(p)

Spec == Init /\ [][Next]_vars

TypeOK ==
/\ existing \subseteq Tricks
/\ edges \in [Pairs -> Statuses]
/\ list.tricks \subseteq Tricks
/\ list.completed \subseteq Tricks

NoSelfEdges == \A p \in Pairs : p[1] # p[2]

SameCategoryEdges ==
\A p \in Pairs : edges[p] # "none" => CatOf(p[1]) = CatOf(p[2])

ApiServesOnlyModerated ==
\A p \in ApiRel : edges[p] \in VisibleStatuses

ApiNeverServesDangling ==
\A p \in ApiRel : p[1] \in existing /\ p[2] \in existing

CompletionWithinList == list.completed \subseteq list.tricks

\* THE learnability property: the prerequisite graph a user can see must
\* never contain a cycle, or the progression it describes is unlearnable.
PrereqAcyclic == ~HasCycle(ApiRel)

=============================================================================

Reading the spec​

  • State: the trick catalog (existing), every possible prerequisite edge with its moderation status (edges, where "none" means the edge does not exist), and one user's trick list.
  • Actions: catalog add/remove; the edge lifecycle draft → reviewed → published (or draft → rejected); list add/complete. RemoveTrick deliberately leaves edges and list entries untouched — that is what the deployed system does.
  • ApiRel encodes the network endpoint's contract: moderated statuses only, dangling references filtered out at read time.
  • Invariants: type safety, no self-edges, category consistency, the two API-contract properties, list-completion containment, and the headline property PrereqAcyclic.

Verification results​

Model checking with TLC​

Model checked with TLC (tla2tools, OpenJDK 21) on 2026‑09‑11.

Design as deployed (GuardedWrites = FALSE), 3 tricks:

Error: Invariant PrereqAcyclic is violated.

TLC produces a 7-state counterexample: add tricks t1, t2; propose and review edge t2 → t1; propose and review edge t1 → t2. Both edges are now visible and form a cycle — the progression is unlearnable. This is not a theoretical concern; see the conformance check below.

Corrected design (GuardedWrites = TRUE):

ModelStates generatedDistinct statesDepthResult
3 tricks (2 categories)18,3613,45618All 7 invariants hold
4 tricks (2 categories)2,529,793331,77627All 7 invariants hold

The guarded design refuses any prerequisite write that would create a cycle among active (non-rejected) edges, which is strictly stronger than guarding the visible subset — an edge can't sneak into a cycle later by being reviewed.

Conformance check of production data​

The spec's static invariants were checked directly against the production trickipedia collection (144 tricks, 807 progression edges):

InvariantResult
No self-edges✅ 0 violations
Referential integrity (no dangling trickId)✅ 0 violations
Category consistency✅ 0 violations
Valid research statuses (185 reviewed, 622 published)✅ 0 violations
Prerequisite acyclicity❌ 2 cycles found

The cycles — ollie-snowboard → tail-press-snowboard → 50-50-snowboard → ollie-snowboard and ollie-snowboard → tail-press-snowboard → boardslide-snowboard → ollie-snowboard — share one edge: the ollie listing the tail press as a prerequisite. Each edge was individually well-reasoned, but together they promised an impossible ordering. That shared edge described itself as "helpful, not mandatory" — exactly the semantics of related, not prerequisites — so the remediation (scripts/fix-snowboard-prereq-cycle.js in the backend repo) reclassifies that single edge, breaking both cycles without losing the pedagogy.

This is the useful lesson of the exercise: TLC predicted the exact bug class from the unguarded design, and the data audit found a live instance of it.

Remediation applied 2026‑09‑11. The edge was reclassified in production and the conformance check now reports zero violations across all five static invariants (144 tricks, 807 edges, cycles: []).

Recommendations​

  1. Write-time guard (mirrors ProposeEdge in the spec): any code path that writes progression.prerequisites — admin tools and migration scripts alike — should reject writes that create a cycle among active prerequisite edges. The check is a transitive-closure test over at most a few hundred edges per category.
  2. CI conformance check: run the static-invariant audit (self-edges, dangling refs, category consistency, statuses, acyclicity) against staging on every migration PR. It is a ~40-line script and it already caught one production bug.
  3. Keep related free of ordering semantics — it is the correct home for "helpful but not required" connections, as the remediation above demonstrates.

Re-running the verification​

# Model checking (spec + configs live in the docs repo under static/tla/)
java -cp tla2tools.jar tlc2.TLC -config Unguarded.cfg TrickipediaNetwork.tla # expect PrereqAcyclic violation
java -cp tla2tools.jar tlc2.TLC -config Guarded.cfg TrickipediaNetwork.tla # expect all invariants to hold

# Live-data conformance (Backend repo)
MONGODB_DATABASE=TrickList2 node scripts/check-trickipedia-invariants.js