Skip to content

Software Architecture

A deep analysis of kel-circle's structure, data flow, and key design patterns. Each section focuses on one architectural concern and includes a diagram.


Package layout

The project is a single Cabal package with two test suites and a companion PureScript client library.

graph TD
    subgraph Haskell["Haskell — kel-circle.cabal"]
        LIB["library<br/>13 exposed modules"]
        SRV["executable<br/>kel-circle-server"]
        UT["test-suite<br/>unit-tests"]
        E2E["test-suite<br/>e2e-tests"]
        SRV --> LIB
        UT  --> LIB
        E2E --> LIB
    end

    subgraph PS["PureScript — client/"]
        PSLIB["kel-circle-client<br/>API · types · codec · fold"]
        PSAPP["kel-circle-trivial<br/>demo UI (Halogen)"]
        PSAPP --> PSLIB
    end

    PSLIB -. "mirrors logic" .-> LIB

Module dependency graph

Arrows point from importer to importee. Foundation modules have no outgoing edges; the server sits at the top.

graph BT
    Types
    Events --> Types
    Sequence
    State --> Types & Events
    Fold --> Sequence
    Proposals --> Types & Events
    Gate --> Types & Events & State
    Processing --> Types & Events & Gate & State & Proposals
    Validate --> Types & Events & Processing
    ServerJSON["Server.JSON"] --> Types & Events & State & Proposals & Validate
    Store --> Types & Events & Processing & ServerJSON
    Server --> Types & Events & State & Processing & Validate & ServerJSON & Store

Core type hierarchy

classDiagram
    class MemberId {
        +unMemberId : Text
    }
    class Role {
        <<enumeration>>
        Admin
        Member
    }
    class Member {
        +memberId : MemberId
        +memberRole : Role
        +memberName : Text
    }
    class CircleState {
        +members : List Member
    }
    class Circle {
        +circleState : CircleState
        +sequencerId : MemberId
    }
    class AuthMode {
        <<enumeration>>
        Bootstrap
        Normal
    }
    class FullState~g,p,r~ {
        +fsCircle : Circle
        +fsAppState : g
        +fsProposals : ProposalRegistry~p,r~
        +fsNextSeq : Int
    }
    class TrackedProposal~p,r~ {
        +tpProposalId : ProposalId
        +tpContent : p
        +tpProposer : MemberId
        +tpDeadline : Timestamp
        +tpResponses : List r
        +tpRespondents : List MemberId
        +tpStatus : ProposalStatus
    }
    class ProposalStatus {
        <<enumeration>>
        Open
        Resolved
    }
    class Resolution {
        <<enumeration>>
        ThresholdReached
        ProposerPositive
        ProposerNegative
        Timeout
    }

    Member "1" --> "1" MemberId
    Member "1" --> "1" Role
    CircleState "1" o-- "0..*" Member
    Circle "1" --> "1" CircleState
    Circle "1" --> "1" MemberId : sequencerId
    FullState "1" --> "1" Circle
    FullState "1" o-- "0..*" TrackedProposal
    TrackedProposal "1" --> "1" ProposalStatus
    ProposalStatus --> Resolution : when Resolved
    AuthMode ..> CircleState : derived from adminCount

Event type hierarchy

classDiagram
    class CircleEvent~d,p,r~ {
        <<union>>
        CEBaseDecision BaseDecision
        CEAppDecision d
        CEProposal p Timestamp
        CEResponse r ProposalId
        CEResolveProposal ProposalId Resolution
    }
    class BaseDecision {
        <<union>>
        IntroduceMember MemberId Text Role
        RemoveMember MemberId
        ChangeRole MemberId Role
        RotateSequencer MemberId
    }
    class EventClass~d,p,r~ {
        <<union>>
        Decision d
        Proposal p Timestamp MemberId
        Response r ProposalId MemberId
    }
    class SequencedEvent~a~ {
        +seqIndex : Int
        +seqTimestamp : Timestamp
        +seqMember : MemberId
        +seqPayload : a
    }

    CircleEvent --> BaseDecision : contains
    SequencedEvent --> CircleEvent : wraps
    EventClass ..> CircleEvent : classifies

Event flow: submission to state update

The path every event travels from the HTTP boundary to the updated in-memory state.

flowchart TD
    CLIENT([Client]) -->|"POST /events<br/>JSON body"| EP["/events endpoint"]

    EP --> DEC{Decode JSON}
    DEC -->|fail| R400["400 Bad Request"]
    DEC -->|ok| MODE{AuthMode?}

    MODE -->|Bootstrap| PP{"Passphrase<br/>present & correct?"}
    PP -->|no| R401["401 Unauthorized"]
    PP -->|yes| GATE

    MODE -->|Normal| GATE

    GATE["Gate validation<br/>baseGate + appGate"] --> GR{Accept?}
    GR -->|no| R422["422 Unprocessable"]
    GR -->|yes| SQL[("SQLite<br/>append row")]

    SQL --> APPLY["Apply to FullState<br/>STM TVar update"]
    APPLY --> BCAST["Broadcast via<br/>SSE channel"]
    APPLY --> R200["200 OK<br/>{sequenceNumber: N}"]

    BCAST --> SSE([SSE subscribers])

Two-level gate validation pipeline

Every base-level decision passes through a chain of Boolean checks before it is accepted.

flowchart LR
    D[BaseDecision] --> PS{protectsSequencer?}
    PS -->|fail| REJ1["❌ rejected"]
    PS -->|pass| UN{hasUniqueName?}
    UN -->|fail| REJ2["❌ rejected"]
    UN -->|pass| BOOT{isBootstrap?}

    BOOT -->|yes| AI{"IntroduceMember<br/>+ Admin?"}
    AI -->|no| REJ3["❌ rejected"]
    AI -->|yes| ACC1["✅ accepted"]

    BOOT -->|no| ADM{signer isAdmin?}
    ADM -->|no| REJ4["❌ rejected"]
    ADM -->|yes| MAJ{requiresMajority?}
    MAJ -->|yes| REJ5["❌ rejected<br/>(needs proposal)"]
    MAJ -->|no| APPG{"appGate<br/>passes?"}
    APPG -->|no| REJ6["❌ rejected"]
    APPG -->|yes| ACC2["✅ accepted"]

Gates for other event types:

flowchart LR
    subgraph AppDecision
        AD[AppDecision] --> ADM2{"isMember<br/>signer?"}
        ADM2 -->|no| R1["❌"]
        ADM2 -->|yes| AG{appGate?}
        AG -->|no| R2["❌"]
        AG -->|yes| A1["✅"]
    end

    subgraph Response
        RE[Response] --> IM{"isMember<br/>signer?"}
        IM -->|no| R3["❌"]
        IM -->|yes| PO{"proposal<br/>open?"}
        PO -->|no| R4["❌"]
        PO -->|yes| HR{"already<br/>responded?"}
        HR -->|yes| R5["❌"]
        HR -->|no| A2["✅"]
    end

    subgraph Resolve
        RV[Resolve] --> IS{"signer ==<br/>sequencerId?"}
        IS -->|no| R6["❌"]
        IS -->|yes| A3["✅"]
    end

Proposal lifecycle state machine

stateDiagram-v2
    [*] --> Open : CEProposal emitted<br/>by any member

    Open --> Open : CEResponse received<br/>(new respondent)

    Open --> Resolved_Threshold : CEResolveProposal<br/>(ThresholdReached)
    Open --> Resolved_PosProposer : CEResolveProposal<br/>(ProposerPositive)
    Open --> Resolved_NegProposer : CEResolveProposal<br/>(ProposerNegative)
    Open --> Resolved_Timeout : CEResolveProposal<br/>(Timeout)

    Resolved_Threshold --> [*]
    Resolved_PosProposer --> [*]
    Resolved_NegProposer --> [*]
    Resolved_Timeout --> [*]

    note right of Open
        Gate checks:
        • signer is member
        • proposal is open
        • signer has not responded yet
    end note

    note right of Resolved_Threshold
        Only the sequencer
        can emit Resolve events
    end note

Bootstrap mode lifecycle

stateDiagram-v2
    [*] --> Bootstrap : server starts<br/>sequencer added<br/>(Member role, no admins)

    Bootstrap --> Bootstrap : passphrase-gated events<br/>(non-admin intros rejected)

    Bootstrap --> Normal : IntroduceMember _ Admin<br/>(first admin enters circle)

    Normal --> Normal : any base/app/proposal<br/>event under admin gate

    Normal --> Bootstrap : last admin demoted<br/>(adminCount → 0)

    note right of Bootstrap
        Only IntroduceMember with
        Admin role is permitted.
        Passphrase required.
    end note

    note right of Normal
        Admin signer required for
        membership operations.
        Proposals needed for role
        changes requiring majority.
    end note

Fold and replay mechanism

The same fold function is used both on startup (full replay from SQLite) and on each new event (incremental update to the TVar).

flowchart TD
    subgraph Startup
        DB[("SQLite<br/>events table")] -->|SELECT all rows| ROWS["Ordered rows<br/>oldest → newest"]
        ROWS --> FOLD["foldAll<br/>applyCircleEvent"]
        FOLD --> FS0[Initial FullState]
        FS0 --> TV[(TVar FullState)]
    end

    subgraph OnNewEvent["On new event"]
        EV[New event] --> VAL[Gate validation]
        VAL --> INS[INSERT INTO events]
        INS --> READ[Read current TVar]
        READ --> APP[applyCircleEvent]
        APP --> WRITE["Write TVar<br/>(STM atomic)"]
    end

    subgraph Query
        TV2[(TVar FullState)] -->|readTVarIO| RESP["HTTP response<br/>or SSE"]
    end

The fold is parameterized — the same infrastructure handles both the fixed base fold and any pluggable application fold:

graph LR
    subgraph twoLayerFold
        BF["baseFold<br/>(membership, roles)"]
        AF["appFold<br/>(domain-specific g)"]
    end
    SEQ["[SequencedEvent (CircleEvent d p r)]"] --> BF & AF
    BF --> BS["CircleState (base)"]
    AF --> AS["g (app state)"]

Persistence layer

erDiagram
    EVENTS {
        int     id          PK
        text    signer
        text    event_json
        text    signature
    }
flowchart LR
    subgraph Store["CircleStore (runtime)"]
        TV["TVar FullState<br/>(hot cache)"]
        CONN["SQLite connection"]
    end

    W[Write path] -->|1 INSERT| CONN
    CONN -->|2 apply + atomically writeTVar| TV

    R[Read path] -->|readTVarIO| TV

    CRASH([Server restart]) -->|SELECT * replay| CONN
    CONN -->|full fold| TV

The hot TVar ensures O(1) reads; SQLite ensures durability across restarts without requiring a separate replay on every query.


Server HTTP interface

sequenceDiagram
    participant C as Client
    participant S as Server
    participant DB as SQLite
    participant CH as SSE channel

    C->>S: POST /events {signer,event,...}
    S->>S: decode + gate validation
    alt rejected
        S-->>C: 401 / 422
    else accepted
        S->>DB: INSERT row
        S->>S: apply → TVar
        S->>CH: broadcast seqN
        S-->>C: 200 {sequenceNumber: N}
    end

    C->>S: GET /events?after=N
    S->>DB: SELECT row at N+1
    alt exists
        S-->>C: 200 {signer, event, signature}
    else missing
        S-->>C: 404
    end

    C->>S: GET /stream
    S-->>C: SSE text/event-stream
    loop on each new event
        CH->>S: seqN
        S-->>C: "event: new data: {sn: N}"
    end

Client sync loop

The PureScript client never trusts state from the server directly — it re-derives state by replaying the event log from its last known checkpoint.

flowchart TD
    START([App start]) --> LOAD["Load checkpoint<br/>from localStorage"]
    LOAD --> CONN["Connect to GET /stream<br/>SSE"]

    CONN --> LISTEN{"SSE event<br/>arrives?"}
    LISTEN -->|yes: sn=N| CMP{N > checkpoint?}
    CMP -->|no| LISTEN

    CMP -->|yes| FETCH[GET /events?after=checkpoint]
    FETCH --> MORE{more events?}
    MORE -->|yes| APPLY["applyCircleEvent<br/>through fold"]
    APPLY --> INC[increment checkpoint]
    INC --> MORE
    MORE -->|no| SAVE["Save checkpoint<br/>to localStorage"]
    SAVE --> LISTEN

    USER([User action]) --> SIGN["Sign event<br/>with private key"]
    SIGN --> POST[POST /events]
    POST --> OK{200 OK?}
    OK -->|yes| INC2[update nextSeq]
    OK -->|no| ERR[Show error]

Server/client trust boundary

graph LR
    subgraph SRV["Server — authority"]
        SEQ["Sequencer identity"] --> ORD["Canonical ordering"]
        GATE["Gate enforcement"] --> ORD
        ORD --> DB["SQLite persistence"]
    end

    subgraph CLI["Client — auditor"]
        KEY["Private key"] -->|signs| POST["POST /events"]
        FOLD["Fold replay"] --> CKPT["Local checkpoint"]
    end

    SRV -->|"signed + ordered event stream"| CLI
    POST -->|"submit event"| SRV
Concern Server Client
Canonical ordering ✗ (audits)
Gate enforcement ✓ (binding) ✓ (UX hint)
Private key
State derivation fold + TVar fold + checkpoint
Signature creation
Signature verification

Apply functions: event → state transition

Each event type maps to exactly one apply function that touches a specific subset of FullState.

flowchart LR
    subgraph FullState
        C[fsCircle]
        G[fsAppState]
        P[fsProposals]
        N[fsNextSeq]
    end

    BD[CEBaseDecision] -->|applyBase| C & N
    AD[CEAppDecision] -->|applyAppDecision| G & N
    PR[CEProposal] -->|applyProposal| P & N
    RS[CEResponse] -->|applyResponse| P & N
    RV[CEResolveProposal] -->|applyResolve| P & N

fsNextSeq is incremented by every apply function — it is the monotonic sequence counter that clients use to detect new events.


Admin majority voting

Role changes requiring admin consensus use the proposal machinery with a hasAdminMajority threshold check.

sequenceDiagram
    participant A as Admin A
    participant S as Server
    participant B as Admin B
    participant C as Admin C

    A->>S: CEProposal (ChangeRole x Admin) deadline=T
    S-->>A: sn=5

    B->>S: CEResponse vote sn=5
    S-->>B: sn=6

    C->>S: CEResponse vote sn=5
    S-->>C: sn=7

    Note over S: adminCount=3, majority=2<br/>2 responses ≥ 2 → threshold met

    S->>S: CEResolveProposal 5 ThresholdReached
    Note over S: sn=8, proposal closed

The majority threshold is adminCount / 2 + 1 — with one admin, that admin can act alone; with three admins, two are needed.


Sequencer rotation

When the sequencer role moves to a new member, the old sequencer's memberName is renamed to its MemberId string to preserve name uniqueness.

sequenceDiagram
    participant Admin
    participant Server

    Note over Server: sequencerId = "seq-0"<br/>members = [{id:"seq-0",name:"sequencer",...}, ...]

    Admin->>Server: CEBaseDecision (RotateSequencer "seq-1")
    Note over Server: rename "seq-0" member → name = "seq-0"<br/>prepend {id:"seq-1", name:"sequencer", role:Member}

    Note over Server: sequencerId = "seq-1"<br/>members = [{id:"seq-1",name:"sequencer"}, {id:"seq-0",name:"seq-0"}, ...]

Testing strategy

graph TD
    subgraph Unit["unit-tests"]
        GEN["Generators<br/>arbitrary MemberId / Role"]
        SEQ2["Sequence<br/>invariants"]
        BD2["BaseDecisions<br/>membership ops"]
        GT["Gate<br/>two-level checks"]
        PR2["Proposals<br/>lifecycle"]
        PR3["Processing<br/>state transitions"]
    end

    subgraph E2E2["e2e-tests"]
        HTTP["Real HTTP server<br/>on random port"]
        SC[Bootstrap scenario]
        MM[Member management]
        RL[Role change proposals]
        RB[Re-bootstrap]
        REP[Event replay]
    end

    subgraph Lean["Lean 4 (formalization)"]
        INV[Invariants]
        THM[Theorems]
    end

    GEN --> SEQ2 & BD2 & GT & PR2 & PR3
    Lean -. "mirrored by" .-> Unit
    Unit -. "composed into" .-> E2E2

Properties are named to mirror Lean theorems: bootstrap_accepts_admin_intro, full_gate_base_rejects, resolution_dichotomy, etc., making the correspondence between the formal proof and the executable tests explicit.