Strength of proof is not a state

I keep my use cases as files. Not classes, not service objects — one flat file per use case, declaring what it accepts, which state it requires, which state it leaves, and what it invalidates. rita renders these headers back out with bin/rita explain; the vocabulary below is the same one.

A grammar that only describes the domain it was written against is not a grammar, it is a schema with opinions. So I took one I had written for a document-heavy domain and ported it to a different one — electronic signature — to see what survived. Most of it did. One thing did not, and that one is the post.

TL;DR

The grammar

Two files carry the vocabulary. The entities are nothing but names and states:

entity :envelope do
  states :drafted, :running, :closed, :cancelled
end

entity :signer do
  states :invited, :authenticated, :signed, :refused
end

entity :delivery do
  states :pending, :delivered, :failed, :cancelled
end

The verbs carry a definition, because the definition is what stops a verb drifting:

verb :verify,         "confirms it against proof that stands on its own"
verb :cross_validate, "confirms it against convergent evidence, when no single source proves it"
verb :close,          "seals the artifact and its audit trail; nothing further changes"
verb :retry,          "attempts a failed hand-off again, without producing a second effect"

Every use case is then <verb>_<entity>, one file, no framework:

command

intent   "Seals the envelope and its signature log once every signer has acted; nothing about it changes again"
accepts  envelope: :ref
requires envelope: :running
leaves   envelope: :closed
returns  :envelope, :audit_log
invalidates :envelope
once     :envelope

once is the uniqueness key, which is also the idempotency key. invalidates is what goes stale. Neither is a comment — they are the parts a reviewer argues with.

The port, and the entity that had nowhere to go

The source grammar had five entities. Two of them merged on contact: what it called a contract and what it called the main aggregate turned out to be the same thing seen twice, which I had not noticed until the second domain put them side by side. A third, the document, lost its state machine entirely — here the lifecycle belongs to the envelope and the document is just content.

The interesting one is the fourth. The source has an exception queue: open an exception, assign an owner and a due date, and settle it with a named person’s signature on a responsibility term. Seven of its thirty-six files exist to run that queue.

It has no counterpart here, and not because I trimmed it.

That single observation is where most of the simplification came from. It is worth saying plainly, because “I simplified it” usually means someone deleted things they did not understand. Here the domain removed them.

The ladder survives

The one piece I most wanted to keep is the split between two ways of believing something:

Rung What counts
Proof that stands on its own A certificate, an identity provider, a bank
Convergent evidence Selfie, one-time token, channel, origin

Two commands, not one command with a confidence: 0.87:

# verify_signer
accepts  signer: :ref,
         source: %w[icp_brasil gov_br bank_account certificate],
         reference: :text, checked_at: :datetime
requires signer: :invited
leaves   signer: :authenticated
# cross_validate_signer
accepts  signer: :ref,
         basis: %w[selfie_document sms_token email_token
                   whatsapp_token geolocation],
         reference: :text, checked_at: :datetime
requires signer: :invited
leaves   signer: :authenticated

A score would have collapsed these into one number, and the number would have been the thing everyone argued about in review while nobody argued about the rungs. The rungs are the decision. The score is a summary of it, and summaries belong in reports, not in state.

What broke

In the source grammar, strength of proof is not recorded anywhere — it is the state. A document sits in verified or in cross_validated, and you can tell how strongly it is believed by looking at where it is. That is a genuinely nice property. It means no field can disagree with the state machine, because there is no field.

It does not survive this domain, and the reason is one line. Here a party has to act after being proved: authenticate, then sign. So the signing use case would have to say

requires signer: :verified or :cross_validated   # not a thing

— two states of one entity in a single requires, which the grammar cannot express. I already knew it could not: the source’s own release command carries a note admitting it, and pushes the real condition out into a separate check. I had read that note a dozen times as a small wart. In the new domain it stopped being small.

So assurance became an attribute. Both commands leave :authenticated, one recording :proof and the other :convergent, and a read compares what was obtained against what the envelope demanded. The note is now in the file, where the next person will hit it:

assumed "assurance is an attribute of the signer, not a state: verify_signer records :proof,
         cross_validate_signer records :convergent, and both leave :authenticated. Stated as
         states, sign_envelope would have to require two states of one entity",
        confidence: :high
The signer state machine after the portA signer starts invited. Two commands lead out of it: verify_signer, taking proof that stands on its own, and cross_validate_signer, taking convergent evidence. Both arrive at the same state, authenticated, one recording an assurance of proof and the other convergent. From authenticated the signer goes to signed or to refused.invitedverify_signercertificate · provider · bankproof that stands alonecross_validate_signerselfie · token · channel · originconvergent evidenceauthenticatedassurance: :proof | :convergentsignedrefused
Two rungs, one state. In the source grammar the two paths ended in two different states and that was the strength; here they converge, and the strength moves into a field on the signer.

That assumed block is part of the grammar too — an assumption with a confidence on it, sitting in the file rather than in someone’s memory. Nine of the twenty-one files carry one. Seven are :high, two :medium.

The honest summary is that the port found a hole in my own DSL, which is the only reason it was worth doing. A port that goes smoothly has told you nothing.

The other half: the hand-off

An envelope changing state owes a webhook to whoever subscribed. That is not the product’s domain, it is the integration team’s, and it gets its own entity:

command

intent   "Records that an envelope event owes a hand-off to a subscribed endpoint"
accepts  envelope: :ref,
         event: %w[envelope.sent signer.signed signer.refused
                   envelope.closed envelope.cancelled],
         subscriber: :ref,
         payload_version: %w[v1 v2]
leaves   delivery: :pending
returns  :delivery
once     :envelope, :event, :subscriber

Two things in there are the whole argument.

once :envelope, :event, :subscriber is the idempotency key, declared in the use case signature rather than buried in a job class. The delivery of a given event to a given subscriber exists at most once, and that is checkable by reading one line. The retry use case then says out loud what the contract is:

the subscriber is responsible for its own idempotency, and the delivery carries the key it needs. At-least-once is the contract, exactly-once is not offered.

payload_version travels with the delivery rather than with the subscriber, which is the less obvious call. Hung off the subscriber, a version migration is a flag flip per endpoint and every consumer moves at once. Hung off the delivery, one endpoint can move event by event. I picked the second because the first is the version of this decision that produces a bad weekend. That is an argument, not a measurement, and it is the line I would expect a reviewer to push back on first.

Creation got the same treatment from the other side. open_envelope takes an external_id from whatever system is calling — a CRM, an ERP, a connector platform — and keys on it, so a caller that retries gets the same envelope back instead of a second one. Idempotency at the edge is cheaper than deduplication in the middle, and it costs one word in the signature.

What this doesn’t show

Nothing here ran. There is no repository to link, no test suite, no production traffic — it is twenty-one files on my disk and an afternoon of argument. Every claim above is about the shape of a design, and design claims are the cheap kind.

What I would want checked, if someone were checking:

The part I would defend is the method rather than the result. Porting a domain model to a neighbouring domain, on purpose, in an afternoon, is an unusually cheap way to find out which of your abstractions were load-bearing and which were just familiar. Mine cost me one rule I liked. That seems like a fair price.