Skip to content

ADR 002 Stateful Issuer

Cursor Agent edited this page Jun 22, 2026 · 6 revisions

ADR-002: Stateful Cap Table with OCF Object References

Status

Implemented | 2026-01-08


TL;DR

Introduce a new CapTable contract that:

  • Maintains Map Text ContractId for all OCF objects (O(1) lookup by business ID)
  • Acts as the sole authority for create/edit/delete operations
  • Validates references on create (can't issue stock to non-existent stakeholder)
  • Provides a batch UpdateCapTable choice for efficient bulk operations

Context

Previous Design Problems (pre–CapTable)

The prior implementation used an event-sourcing pattern where the Issuer contract acted as a factory with ~40+ nonconsuming choices. That design created several problems:

Problem Impact
No current state visibility Must replay all events off-chain to determine ownership
No reference validation Can issue stock to non-existent stakeholders or invalid stock classes
Scattered data Cap table spread across many independent contracts
Inefficient bulk operations N operations require N new CapTable contracts

Decision

Introduce a new CapTable contract (separate from the OCF Issuer object):

  1. Single CapTable contract per cap table maintains Maps of id → ContractId for all OCF objects
  2. The Issuer remains a simple OCF object (just data, no factory methods)
  3. All create/edit/delete operations go through CapTable
  4. CapTable validates references exist before allowing transactions (O(1) map lookup)
  5. Edit = archive old + create new + update ContractId in map
  6. Delete = archive contract + remove from map

Architecture

Factory Creates CapTable

The OcpFactory creates a CapTable contract given Issuer data. The Issuer is required to initialize a cap table and cannot be added or removed—only edited.

choice CreateCapTable(issuer_data):
    -- Create the Issuer OCF contract
    issuer_cid <- create Issuer(context, issuer_data)

    -- Create CapTable with Issuer reference and empty maps
    create CapTable with {
        issuer = issuer_cid,
        issuer_id = issuer_data.id,
        -- All other maps start empty
        stakeholders = Map.empty,
        stock_classes = Map.empty,
        ...
    }

CapTable Structure

The CapTable maintains maps organized by OCF schema categories:

graph LR
    subgraph CapTable["CapTable Contract"]
        direction TB
        subgraph " "
            direction LR
            Issuer["Issuer<br/><i>exactly 1, edit only</i>"]
            Objects["Objects<br/><i>stakeholders, stock_classes,<br/>stock_plans, vesting_terms, ...</i>"]
            Transactions["Transactions<br/><i>issuances, transfers,<br/>cancellations, exercises, ...</i>"]
        end
    end

    CapTable -->|"Add/Edit/Delete"| OCF

    subgraph OCF["OCF Contracts"]
        direction LR
        C1[Issuer]
        C2[Stakeholder]
        C3[StockClass]
        C4[StockIssuance]
        C5[...]
    end
Loading
Category Maps Notes
Issuer issuer: ContractId Issuer Exactly 1. No Add/Delete, only Edit.
Objects stakeholders, stock_classes, stock_plans, stock_legend_templates, vesting_terms, valuations, documents Add/Edit/Delete
Transactions stock_issuances, stock_transfers, stock_cancellations, equity_compensation_issuances, convertible_issuances, warrant_issuances, ... Add/Edit/Delete
Security ID indexes stock_issuances_by_security_id, convertible_issuances_by_security_id, equity_compensation_issuances_by_security_id, warrant_issuances_by_security_id O(1) lookup for security_id on downstream transactions

Key Points

  • CapTable is a new custom contract — not an OCF object
  • Issuer is exactly 1 — created with CapTable, can only be edited
  • All OCF contracts remain unchanged — just remove ArchiveByIssuer choice
  • Same signatories — CapTable can directly archive OCF contracts
  • Maps for O(1) lookup — instant validation by business ID

Batch API

A single UpdateCapTable choice handles all create/edit/delete operations:

choice UpdateCapTable : UpdateCapTableResult
  with
    creates: [OcfCreateData]
    edits: [OcfEditData]
    deletes: [OcfDeleteData]
  controller context.issuer

Benefits:

  • Efficiency: N operations = 1 new CapTable (not N)
  • Atomicity: All operations in a batch succeed or fail together
  • Intra-batch dependencies: Create a Stakeholder and a StockIssuance referencing it in the same batch

Sum Types for Batch Input

-- Creates: sum type with one constructor per OCF type
data OcfCreateData
  = OcfCreateStakeholder StakeholderOcfData
  | OcfCreateStockClass StockClassOcfData
  | OcfCreateStockIssuance StockIssuanceOcfData
  -- ... all 47 types

-- Edits: sum type wrapping the OCF data (ID is in the data record)
data OcfEditData
  = OcfEditStakeholder StakeholderOcfData
  | OcfEditStockClass StockClassOcfData
  -- ... all types

-- Deletes: tagged by type (constructor carries the business id)
data OcfDeleteData
  = OcfDeleteStakeholder Text
  | OcfDeleteStockClass Text
  -- ... all types

Processing Order (Tiers)

Creates are processed in tier order to support intra-batch dependencies.

Tier Types Rationale
1 Stakeholder, StockClass, StockPlan, VestingTerms, StockLegendTemplate, Document Base objects with no OCF dependencies
2 Valuation, StakeholderRelationshipChangeEvent, StakeholderStatusChangeEvent, Stock class adjustments Depend on Tier 1 objects
3 StockIssuance, EquityCompensationIssuance, ConvertibleIssuance, WarrantIssuance Issuances reference stakeholders/classes
4 All other transactions (Transfers, Cancellations, Exercises, etc.) Depend on issuances

Edits and deletes process after all creates, in the same tier order.

Return Type

data UpdateCapTableResult = UpdateCapTableResult with
    updatedCapTableCid: ContractId CapTable
    createdCids: [OcfContractId]
    editedCids: [OcfContractId]

OcfContractId is a sum type of strongly-typed ContractId wrappers (e.g. CidStakeholder (ContractId Stakeholder), CidIssuer (ContractId Issuer)).

Code Generation

The batch structure is generated by scripts/codegen/generate-captable.ts from:

  • Type discovery: Scans OpenCapTable-v35/daml/Fairmint/OpenCapTable/OCF/ for DAML files
  • Tier configuration: scripts/codegen/captable-config.yaml
  • Validation rules: Same config file specifies which references to validate

Lifecycle Operations

Issuer (Edit Only)

The Issuer is created with the CapTable and cannot be added or deleted—only edited. Since v31, there is no standalone EditIssuer choice; issuer updates use OcfEditIssuer in the UpdateCapTable batch (issuer is a single ContractId reference, not a map entry):

-- Via UpdateCapTable batch (edits list must contain only OcfEditIssuer):
OcfEditIssuer new_data -> do
    assert issuer_id == new_data.id
    archive issuer
    new_cid <- create Issuer(context, new_data)
    -- CapTable continues with issuer = new_cid

Objects: Create / Edit / Delete

Create:

-- Via UpdateCapTable batch:
OcfCreateStakeholder data -> do
    assert data.id not in stakeholders
    new_cid <- create Stakeholder(context, data)
    return updated maps with stakeholders = Map.insert data.id new_cid stakeholders

Edit:

OcfEditStakeholder new_data -> do
    old_cid <- lookup new_data.id stakeholders
    assert (isSome old_cid) "Stakeholder not found"
    archive (fromSome old_cid)
    new_cid <- create Stakeholder(context, new_data)
    return updated maps with stakeholders = Map.insert new_data.id new_cid stakeholders

Delete:

OcfDeleteStakeholder id -> do
    cid <- lookup id stakeholders
    assert (isSome cid) "Stakeholder not found"
    archive (fromSome cid)
    return updated maps with stakeholders = Map.delete id stakeholders

⚠️ Note: Delete does not validate reverse references. Deleting an object that is referenced by transactions will leave dangling references.


Consequences

  • Reference validation on create — O(1) validation that referenced objects exist
  • Clean separation — CapTable is custom logic; OCF objects stay standard
  • Queryable state — Maps show what exists by ID
  • Atomic batch operations — Multi-step operations in single transaction
  • Efficient bulk updates — N operations = 1 CapTable update
  • OCF compliance — Issuer and all objects remain in standard OCF format

References

Clone this wiki locally