Imagine an agent with a task: email a receipt. The function it finds takes two strings, customerId and email. It passes them in the wrong order. The code compiles, but the receipt goes to the wrong place or fails to send.

A human can make the same mistake but remember the convention from last week's review. Unless the agent receives that context, it must infer the convention from names, types, and the prompt. When both types are String, the signature loses the distinction that the domain needs.

Programs forget this way all the time. A validator accepts an email as well-formed, then returns a String. Authentication identifies the caller, then passes along an ordinary request. Every later step must derive again, or simply assume, what an earlier step already knew.

I call the fix the Semantic Ratchet. A ratchet turns one way and resists reverse movement. When a program establishes a fact, its representation must carry that fact forward. Later code must accept that representation instead of an unchecked primitive. A parser returns a stronger value. The next function accepts only that value.

A comment asks the next author to remember a rule. A constrained type makes the compiler remember it. This differs from a CI ratchet that records existing violations and rejects new ones. Here, the ratchet lives in the types.

More context can help. Prompts, repository summaries, examples, and memory can explain intent. For facts the program already establishes, an enforced contract can preserve that intent across callers and edits.

Reduce the state space

Consider this interface:

fun sendReceipt(customerId: String, email: String)

A caller can reverse the arguments or pass an unrelated string. Those calls remain type-correct.

Now give each concept a distinct type:

fun sendReceipt(
    customerId: CustomerId,
    email: EmailAddress,
)

The compiler rejects swapped arguments. It does not prove that the caller chose the correct customer or address. Distinct types remove one specific mistake from the set of accepted programs.

Names alone establish no validity constraint. A type alias creates no distinct type. An unchecked wrapper accepts the same invalid input under a new name. An EmailAddress needs controlled construction under an explicit address policy. Even then, valid syntax proves neither mailbox ownership nor delivery.

Use the smallest representation that excludes the named mistake. A finite choice can use an enum. A case with its own data can use a sealed hierarchy. A non-empty collection can store a head and a tail. A numeric range can use a constrained constructor.

The useful question is precise: which incorrect program will this representation reject?

Make the program remember

Many designs establish facts and then discard them:

  • A validator accepts an email, but consumers still receive a String.
  • Authentication establishes identity, but consumers still receive an ordinary request.
  • A service establishes eligibility, but consumers still receive a generic account.

The representation leaves later callers responsible for the earlier reasoning. Alexis King's Parse, don't validate describes how a stronger result preserves that knowledge.

For example, a page size must lie between 1 and 100. This Kotlin example gives that rule one owner:

@JvmInline
value class PageSize private constructor(private val count: Int) {
    fun <T> takeFrom(items: List<T>): List<T> = items.take(count)

    companion object {
        fun parse(raw: Int): PageSizeParse =
            if (raw in 1..100) {
                PageSizeParse.Accepted(PageSize(raw))
            } else {
                PageSizeParse.OutOfRange(raw)
            }
    }
}

sealed interface PageSizeParse {
    data class Accepted(val size: PageSize) : PageSizeParse
    data class OutOfRange(val supplied: Int) : PageSizeParse
}

The parser accepts an external integer. Success carries a PageSize. Rejection carries the supplied integer in a distinct case. Expected failure needs no exception, nullable sentinel, or unexplained Boolean.

A consumer carries the stronger value forward:

fun <T> firstPage(items: List<T>, size: PageSize): List<T> =
    size.takeFrom(items)

firstPage(items, 0) fails to compile. PageSize(0) also fails because the constructor is private. The parser rejects both 0 and 101. It accepts both 1 and 100. These cases exercise different obligations: the compiler controls calls, while runtime assertions examine the parser's rule.

The compiler does not prove that the parser implements the intended range. A private constructor controls access to the implementation that establishes the fact. That implementation still needs review and executable evidence.

An untrusted integer enters a parser. Success carries PageSize to firstPage. Rejection carries OutOfRange to the caller.
The parser establishes the range. The consumer accepts the value that preserves it.

PageSize is one click. A ratchet needs several. Here, authentication produces a principal, authorization produces a permit, and the write accepts that permit:

class Principal private constructor(val userId: String) {
    companion object {
        fun authenticate(token: String): Authentication =
            userIdFor(token)?.let { Authentication.Authenticated(Principal(it)) }
                ?: Authentication.Rejected
    }
}

sealed interface Authentication {
    data class Authenticated(val principal: Principal) : Authentication
    data object Rejected : Authentication
}

class CanEdit private constructor(val document: DocumentId) {
    companion object {
        fun check(who: Principal, doc: Document): EditCheck =
            if (who.userId in doc.editors) EditCheck.Granted(CanEdit(doc.id))
            else EditCheck.Denied(who.userId, doc.id)
    }
}

sealed interface EditCheck {
    data class Granted(val permit: CanEdit) : EditCheck
    data class Denied(val userId: String, val document: DocumentId) : EditCheck
}

fun saveEdit(permit: CanEdit, text: String): Revision =
    writeRevision(permit.document, text)

The caller must handle rejection at each step:

fun handleEdit(token: String, doc: Document, text: String): Response =
    when (val auth = Principal.authenticate(token)) {
        Authentication.Rejected -> Response.Unauthorized
        is Authentication.Authenticated ->
            when (val check = CanEdit.check(auth.principal, doc)) {
                is EditCheck.Denied -> Response.Forbidden
                is EditCheck.Granted -> Response.Saved(saveEdit(check.permit, text))
            }
    }

Each protected step accepts proof from the previous step. saveEdit takes no separate document ID because the permit carries it. The caller cannot pass a permit for one document and an ID for another. Private constructors prevent external callers from constructing either Principal or CanEdit.

The example omits Document, DocumentId, Revision, and Response definitions. The userIdFor adapter returns a user ID for an accepted token or null for rejection. authenticate converts that nullable result into the closed Authentication outcome. The writeRevision adapter writes to permit.document. These adapters stand for authentication and storage operations whose implementations depend on the application.

The permit proves that the permission check passed when it was issued. This sketch binds the edit action and document. A production capability must bind the principal, action, resource, and version or lifetime that the policy needs. Expiry and revocation still belong to the adapter that does the write. That adapter needs the identity and policy data necessary to establish freshness. The sketch omits those details and does not prove current permission at write time.

Close the expected outcomes

PageSizeParse also closes the set of expected outcomes. A consumer handles each case explicitly:

fun describe(result: PageSizeParse): String = when (result) {
    is PageSizeParse.Accepted -> "Page size accepted"
    is PageSizeParse.OutOfRange -> "Outside 1..100: ${result.supplied}"
}

Kotlin's sealed hierarchies let the compiler examine whether this when expression covers every case. If the hierarchy gains another case, this expression must handle it before compilation succeeds. An else branch removes that signal.

Each case needs exactly the data that its meaning demands. A payment approval carries its receipt. A decline carries its reason. A demand for further action carries that action. A status string with optional fields permits combinations that the domain rejects.

A closed internal model does not close an external service's vocabulary. An adapter must handle unknown external values explicitly. It must preserve uncertainty or reject the input without inventing a successful domain value.

Preserve facts through each phase

Compilers provide a useful model for this design. A compiler can resolve a name to a declaration and preserve that identity in later representations. Later phases then consume the resolved identity instead of guessing from the spelling.

Source becomes a syntax tree, then resolved symbols, then typed operations, then executable code. Each phase preserves facts for later work.
This conceptual compiler pipeline shows how later phases consume earlier decisions.

The edit example uses the same progression: a token becomes a principal, then a permit, then a revision. Each consumer accepts the representation that carries the fact it needs.

External facts can change. A permission can expire, and another transaction can change an account. Ordinary Kotlin types do not prove single use, prevent capability reuse, or establish transaction isolation.

Keep pure decisions separate from filesystem, network, clock, and database effects. Pass effect results into the core as explicit values. Keep each expected failure distinct when callers need a different response. Serialize domain values at transport adapters instead of reducing internal contracts to strings and maps.

Use compiler facts within their scope

A model can choose the wrong overload, examine the wrong implementation, and propose a coherent change to the wrong path. A compiler-derived symbol identity can eliminate that specific guess.

Use semantic tools for symbol identity, resolved types, references, and relationships that the tool can establish. Inspect the tool's scope and completeness before treating a result as authoritative. Dynamic dispatch, reflection, generated code, and runtime configuration can leave relationships unresolved. An empty result does not prove absence when coverage is incomplete.

The model can decompose intent and compose candidate changes. Compiler evidence constrains claims about the program. Runtime evidence establishes what an external interaction actually did. Each source of evidence answers a different question.

Intent and source evidence inform a candidate change. Compiler, schema, and behavioral checks reject violations. Accepted candidates still need review and evidence for external effects.
Precise rejection guides the next candidate. Acceptance remains limited to the obligations that the tools examine.

Design for precise rejection

AI-assisted work makes repeatable feedback valuable because each proposed change needs a decision about correctness. Faster generation does not remove that decision. It gives an implicit rule more opportunities to disappear from the next candidate. A prompt can describe an invariant. A compiler, schema, database constraint, or focused behavioral assertion can reject a candidate that violates an encoded rule.

These mechanisms prove different things. Compilation establishes the encoded type obligations. A schema establishes declared payload constraints. A behavioral assertion compares an actual result with an independently chosen expectation. None proves complete correctness.

For each ratchet, name the fact, its owner, its construction path, its consumer, and the forbidden call or transition. Then exercise one legal path and the nearest invalid case. For PageSize, keep the accepted endpoints, rejected neighbors, private-constructor rejection, raw-integer rejection, and incomplete-when rejection as separate evidence.

Do not weaken the contract to silence the feedback. A cast, unchecked factory, catch-all branch, or premature conversion to a primitive can erase the fact that the design needs. When a rule changes, change its owner and repair the consumers that the compiler identifies.

This discipline does not need a type for every local predicate. A fact that never leaves one small function can stay local. A fact that later behavior needs must survive the journey to that behavior.

The value of the Semantic Ratchet is that each established fact reduces the reasoning that every later author must repeat. That author can be a maintainer, a new teammate, or an AI agent.

Further reading