A cap table answers one question: how far can volume fall before a margin line breaks. Most of the ones I have read this year cannot answer it, and not because the arithmetic is wrong. It is because the table contains rows that are not caps, and nothing in the table says which ones.

The test fits in one line.

A step caps a distance only if its trigger is a function of the axis variable.

If the axis is completed payments per month, a row can bound a distance in completed payments only if the thing that fires it moves with completed payments. A condition that can be true at any volume cannot say how far you can go.

Three rows that look identical in a spreadsheet, axis N = completed payments per month:

row trigger bounds a distance in N?
volume tier: above 20,000 completions, lower fee N yes, the trigger is the axis
accounts over 50 seats get 10% off headcount no, fires at N = 400 or N = 90,000
commitment renews 1 April a date no, fires at any N

The second and third are real terms. They are not caps. A row that says "cap" and triggers on headcount is worse than no row at all, because it occupies the place where the answer should be and nobody goes looking for the missing one.

What replaces it is a second table. Which is the rest of this post.

A cap row has one job: it measures an interval

Take a merchant acquirer with three invented numbers:

  • N0 = 25,000 completions per month
  • p0 = 9.0p margin per completion
  • F = 180,000p fixed cost per month (£1,800)

Break-even volume is where N * p = F, so N = F / p = 20,000. Distance down is 25,000 - 20,000 = 5,000 completions. That is the row.

Now add the volume tier: above 20,000 completions the fee drops and the margin per completion is 8.0p. Break-even becomes 180,000 / 8.0 = 22,500, and the distance is 2,500.

The table that prints 5,000 is not miscalculated. Every number in it is correct. It measured an interval in which 9.0p applies to no completion at all: from 22,500 up to 25,000, every payment earns 8.0p. The row was computed with the margin at the top of the page rather than the margin in force over the range it claims to measure.

That is the whole defect in one row. A cap row may only use a p that holds over the entire interval between the break-even and the volume you are at. If p changes inside that interval, the row is not approximate. It is measuring nothing.

The same thing happens across time, which is harder to see. Raise a rail cost by 0.4p on 15 March and p goes from 9.0 to 8.6. The distance becomes 4,070. Now the table has three numbers, 5,000 and 4,070 and 2,500, all correct, and no two of them comparable:

row p in force distance down what the reader is told
pre-March, no tier 9.0p 5,000 right for a merchant that no longer exists
post-March, no tier 8.6p 4,070 right as of a date the row does not print
pre-March, tier above 20,000 8.0p 2,500 right, and half of what the flat row says
post-March, tier above 20,000 7.6p 1,316 right, and a quarter of the flat row

A single p for the whole table cannot show that rows either side of a revision are incomparable, because the thing that changed is in no row. Two true numbers, a meaningless pair.

Two tables, two readers, two different times

The fix is not a better column. It is two tables with different keys, read by different people at different moments.

The cap table, keyed on N. Columns: from_N, the p in force as of that row's date, the distance, and valid_until. Read once, by whoever is deciding how far they can go.

The revision log, keyed on a date. Columns: the date, which component of p moved, old value, new value. Read at settlement, by whoever is reconciling a row against what actually happened.

The readers do not overlap. The cap table answers "how far can we go". The revision log answers "which p did this row use". One table cannot do both jobs, because the first is keyed on volume and the second on time, and a row with two keys is a row that can disagree with itself.

Each cap row then carries p as of its own date, not the table's. That is boring, it is what an as-of stamp is for, and it is the part that gets skipped because nothing fails in a demo.

The validity column carries an owner

valid_until on its own lies quietly, because three parties set the clocks and they do not agree:

  • the source sets the fact revision (p is only true while the rail cost behind it holds)
  • the issuer sets the authorisation window on a card payment
  • the contract sets its own renewal date
  • your own model sets the window you invented

So the column is valid_until plus owner. When a row's expiry is owned by a party other than the one the row is being compared against, the honest render is NOT_COMPARABLE reason=expiry_owner_mismatch, not expired. And when the next non-volume step is not known yet, the row prints unknown. It does not borrow a date from the renewal clause to look finished. A borrowed date is a claim the row cannot support.

Five things to check before a table is a table

  1. Every row triggers on the axis variable. Rows that trigger on headcount or a date move to the terms log.
  2. Every row names the p in force over its own interval, and the interval does not cross a change in p.
  3. Every row carries an as-of date for its p.
  4. Every row's expiry names its owner, and prints unknown when there is none.
  5. The delta column is not stored. Distances and differences are derived at read time, because a stored third number is one that can disagree with the two it came from.

A table that passes the first four and fails the fifth will still print numbers. It will print them until somebody reconciles one and finds the pair was never comparable. That is the failure mode worth naming: nothing errors, the row looks the same, and the only person who can see it is the one who asks which p the row was measured with.

Where this came from

This is the second half of an argument running under arun rajkumar's piece on payment webhooks and reconciliation. His break-even distances were the raw material; the axis test and the owner column came out of that thread and he took the corrections further than I did. The reconciliation version of the same defect is in the intent-record thread, where it renders as reference_mismatch: every number right, the pair meaningless.

If you have a cap table that passes item 1 and fails item 2, send me the row. That is the case I want to see, and it is one line to check.