soma.cell

Four-eyes approval — the maker cannot be the checker, by construction

Wirecard collapsed in 2020 with €1.9 billion that never existed; for years the same small circle that originated transactions also signed off on them. Every controls framework since (SOX, Basel, ISO 27001) demands "segregation of duties": the person who drafts a document may not be the person who approves it. Usually that is a checkbox in a workflow tool — deletable, bypassable. Here it is arithmetic the memory enforces. Each document/person pair holds AT MOST ONE role. Authoring writes 1. Approving adds 1. If the author tries to approve their own document the write becomes 2 — and the invariant rejects it. The self-approval is not caught by a check; it is a value the ledger cannot hold.

A Soma program from the governance part of the verified corpus: 115 lines, cells ApprovalChain, FourEyesTests. It passes soma check with soma 3.2.4.

How it is built

The cell ApprovalChain declares 5 signals in its face: draft_doc(doc: String, author: String) -> Map, submit_doc(doc: String) -> Map, approve_doc(doc: String, approver: String) -> Map, send_back(doc: String) -> Map and role_count(doc: String, person: String) -> Int; keeps 1 memory slot: roles (Map<String, Int>, persistent and consistent) under the invariant roles >= 0 && roles <= 1. Its state machine doc starts in blank and allows 4 transitions: blank -> drafted, drafted -> submitted, submitted -> approved and submitted -> drafted. approved is terminal. Handlers: draft_doc (line 45), submit_doc (line 52), send_back (line 58), approve_doc (line 64) and role_count (line 81).

The file also holds 1 test cell (FourEyesTests) with 15 assertions, run by soma test.

Generated from soma describe on this file.

The source

four_eyes_approval.cell

// ============================================================================
// FOUR-EYES APPROVAL — the maker cannot be the checker, by construction
// ============================================================================
//
// Wirecard collapsed in 2020 with €1.9 billion that never existed; for
// years the same small circle that originated transactions also signed
// off on them. Every controls framework since (SOX, Basel, ISO 27001)
// demands "segregation of duties": the person who drafts a document may
// not be the person who approves it. Usually that is a checkbox in a
// workflow tool — deletable, bypassable.
//
// Here it is arithmetic the memory enforces. Each document/person pair
// holds AT MOST ONE role. Authoring writes 1. Approving adds 1. If the
// author tries to approve their own document the write becomes 2 — and
// the invariant rejects it. The self-approval is not caught by a check;
// it is a value the ledger cannot hold.
// ============================================================================

cell ApprovalChain {
    face {
        signal draft_doc(doc: String, author: String) -> Map
        signal submit_doc(doc: String) -> Map
        signal approve_doc(doc: String, approver: String) -> Map
        signal send_back(doc: String) -> Map
        signal role_count(doc: String, person: String) -> Int
    }

    memory {
        // doc:person -> number of roles held. 1 = authored OR approved.
        // 2 (authored AND approved the same doc) is unrepresentable.
        roles: Map<String, Int> [persistent, consistent]
        invariant roles >= 0 && roles <= 1
    }

    // approved is TERMINAL: a signed document cannot quietly re-enter
    // drafting to be edited after the signature.
    state doc {
        initial: blank
        blank -> drafted
        drafted -> submitted
        submitted -> approved
        submitted -> drafted
    }

    on draft_doc(doc: String, author: String) {
        let r = try { transition(doc, "drafted") }
        if r.error != () { return map("ok", false, "reason", "{r.error}") }
        roles.set("{doc}:{author}", 1)
        return map("ok", true, "doc", doc, "author", author)
    }

    on submit_doc(doc: String) {
        let r = try { transition(doc, "submitted") }
        if r.error != () { return map("ok", false, "reason", "{r.error}") }
        return map("ok", true, "state", "submitted")
    }

    on send_back(doc: String) {
        let r = try { transition(doc, "drafted") }
        if r.error != () { return map("ok", false, "reason", "{r.error}") }
        return map("ok", true, "state", "drafted")
    }

    on approve_doc(doc: String, approver: String) {
        if get_status(doc) != "submitted" {
            let s = get_status(doc)
            return map("ok", false, "reason", "cannot approve from state {s}")
        }
        // THE WALL: author already holds 1 role on this doc; this write
        // would make 2 — the memory refuses, no matter who deleted which
        // if-statement upstream.
        let k = "{doc}:{approver}"
        let w = try { roles.set(k, (roles.get(k) ?? 0) + 1) }
        if w.error != () {
            return map("ok", false, "reason", "four-eyes violation: {approver} authored {doc}")
        }
        transition(doc, "approved")
        return map("ok", true, "doc", doc, "approver", approver)
    }

    on role_count(doc: String, person: String) {
        return roles.get("{doc}:{person}") ?? 0
    }
}

cell test FourEyesTests {
    rules {
        // normal flow: alice drafts, bob approves
        assert draft_doc("D1", "alice").ok == true
        assert approve_doc("D1", "bob").ok == false      // not submitted yet
        assert submit_doc("D1").ok == true

        // THE FORBIDDEN ACT: alice approving her own document.
        // The role ledger cannot hold 2 — the write is refused.
        assert approve_doc("D1", "alice").ok == false
        assert_fails roles.set("D1:alice", 2)
        assert role_count("D1", "alice") == 1
        assert get_status("D1") == "submitted"           // unharmed

        // a different pair of eyes goes through
        assert approve_doc("D1", "bob").ok == true
        assert get_status("D1") == "approved"

        // approved is terminal: no quiet re-edit after signature
        assert_fails transition("D1", "drafted")
        assert_fails transition("D1", "submitted")
        assert send_back("D1").ok == false

        // alice may still approve a DIFFERENT author's document
        assert draft_doc("D2", "bob").ok == true
        assert submit_doc("D2").ok == true
        assert approve_doc("D2", "alice").ok == true
    }
}

What the toolchain says

Run on a fresh copy of the program at build time; the outputs are pasted unedited.

$ soma check four_eyes_approval.cell
✓ All checks passed.
$ soma test four_eyes_approval.cell
test FourEyesTests ...
  ✓ assert draft_doc("D1", "alice").ok == true
  ✓ assert approve_doc("D1", "bob").ok == false
  ✓ assert submit_doc("D1").ok == true
  ✓ assert approve_doc("D1", "alice").ok == false
  ✓ assert_fails roles.set("D1:alice", 2) — raised memory invariant violated on 'roles': roles >= 0 && roles <= 1 — rejected write of 2 (key "D1:alice"); the slot is unchanged
  ✓ assert role_count("D1", "alice") == 1
  ✓ assert get_status("D1") == "submitted"
  ✓ assert approve_doc("D1", "bob").ok == true
  ✓ assert get_status("D1") == "approved"
  ✓ assert_fails transition("D1", "drafted") — raised invalid transition: approved → drafted. Current state: 'approved'. Valid targets: []
  ✓ assert_fails transition("D1", "submitted") — raised invalid transition: approved → submitted. Current state: 'approved'. Valid targets: []
  ✓ assert send_back("D1").ok == false
  ✓ assert draft_doc("D2", "bob").ok == true
  ✓ assert submit_doc("D2").ok == true
  ✓ assert approve_doc("D2", "alice").ok == true

15 tests: 15 passed, 0 failed
$ soma verify four_eyes_approval.cell
Verifying four_eyes_approval.cell...

State machine 'doc': 4 states, initial 'blank'
  States: [approved, blank, drafted, submitted]

  ✓ 4 states, 4 transitions
  ✓ all states reachable from 'blank'
  ✓ terminal states: [approved]
  ✓ no deadlocks
  ✓ liveness: every state can eventually reach a terminal state
  ✓ think-isolated: CTL safety properties hold regardless of LLM output (4 handlers, 4 literal transitions, 0 dynamic)
  ✓ termination: all 5 handlers structurally terminate
  ✓ refinement: handler `draft_doc` ⟶ {drafted}
  ✓ refinement: handler `submit_doc` ⟶ {submitted}
  ✓ refinement: handler `send_back` ⟶ {drafted}
  ✓ refinement: handler `approve_doc` ⟶ {submitted → approved}
  · transition sources: `draft_doc`, `submit_doc`, `send_back` call transition(id, target) without declaring the source — the runtime refuses a move the graph forbids, but the proof does not see where the handler starts; `transition(id, from, to)` names the edge (checked by `soma check`, refused at run time from any other state)

ApprovalChain/invariants:

  ✓ invariant roles >= 0 — writer 'approve_doc' proven by induction (writes (roles.get(k) ?? 0) + 1)
  ✓ invariant roles >= 0 && roles <= 1 — writer 'draft_doc' proven (writes 1)
  ⚠ invariant roles >= 0 && roles <= 1 — runtime-checked (computed values): approve_doc → roles [roles <= 1] because `k` = "{doc}:{approver}" — not bounded; `roles` has no known range

13 passed, 1 warnings, 0 failures

  Temporal properties for 'doc':
  ✓ deadlock_free — no deadlocks in any reachable state
Temporal: 1 passed, 0 failed
VERIFY OK

Builtins used

get_status, map, transition

Source file: /corpus/governance/four_eyes_approval.cell · examples/corpus/governance/four_eyes_approval.cell