-
Notifications
You must be signed in to change notification settings - Fork 0
ADR 002 Stateful Issuer
Implemented | 2026-01-08
Introduce a new CapTable contract that:
- Maintains
Map Text ContractIdfor 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
UpdateCapTablechoice for efficient bulk operations
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 |
Introduce a new CapTable contract (separate from the OCF Issuer object):
- Single
CapTablecontract per cap table maintains Maps of id → ContractId for all OCF objects - The
Issuerremains a simple OCF object (just data, no factory methods) - All create/edit/delete operations go through
CapTable -
CapTablevalidates references exist before allowing transactions (O(1) map lookup) - Edit = archive old + create new + update ContractId in map
- Delete = archive contract + remove from map
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,
...
}
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
| 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 |
- 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
ArchiveByIssuerchoice - Same signatories — CapTable can directly archive OCF contracts
- Maps for O(1) lookup — instant validation by business ID
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
-- 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
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.
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)).
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
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
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.
- 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
- Contract-Diagram - Visual Mermaid diagrams of the contract architecture
- OCF Schema
- ADR-001: OCF Cap Table on Canton
- Canton Network Documentation
Develop
Decisions
Releases
- Releases (index)
- Package tag release process
- 2026-06-22 — OCP v35 conversion mechanism validation
- 2026-06-22 — OCP v35 SDK scaffold
- 2026-05-04 — OCP v34 major upgrade
- 2026-05-04 — DAML extraction / fairmint-daml
- 2026-03-05 — OCP v32 PPS-based conversion rename
- 2026-02-17 — OCP v31 ArchiveCapTable
- 2026-02-12 — OCP v31 vesting zero portion
Other Fairmint DAML
Documentation for CantonPayments, NFT, Reports, CouponMinter, equity certificate, proof-of-ownership, and related deployment playbooks lives with fairmint/daml and @fairmint/daml-js — not in this wiki.