The Constraint Escalation Ladder: Choosing the Right Prevention Layer

James Phoenix
James Phoenix

When you catch something in the codebase, pick the lightest durable fix. If you jump straight to ESLint each time, you skip the layers that produce the strongest convergence.

Author: James Phoenix | Date: February 2026


Summary

Every time you catch a problem in agent-generated code, you have a choice of prevention layer: types, library primitives, runtime invariants, tests, or lint rules. Each layer has different strength, cost, and durability. The ladder prioritizes the layers that make invalid states impossible (types) over the layers that detect invalid states after the fact (lint). Use the “three strikes” rule: first occurrence is a local fix, second is a type or primitive, third gets a lint rule.


The Ladder

From strongest (top) to weakest (bottom):

Level 1: Types / Signatures           Make invalid states unrepresentable
Level 2: Library Primitives            Wrap the dangerous thing once
Level 3: Runtime Invariants            Guard at system boundaries
Level 4: Tests                         Verify behavior mechanically
Level 5: ESLint / Lint Rules           Ban structural patterns

Each level is appropriate for different classes of problems. The goal is always to use the highest level that fits.


Level 1: Types and Signatures (Best)

Make invalid states unrepresentable at compile time.

When to use: The problem is a category error, a wrong shape of data, or a missing constraint that the type system can express.

Examples:

// BAD: tenant_id is just a string, easy to forget
function getUser(tenantId: string, userId: string): User { ... }

// GOOD: branded type makes mixing up IDs a compile error
type TenantId = string & { readonly __brand: 'TenantId' };
type UserId = string & { readonly __brand: 'UserId' };
function getUser(tenantId: TenantId, userId: UserId): User { ... }
// BAD: error handling is "remember to try/catch"
async function processPayment(amount: number): Promise<Result> { ... }

// GOOD: Effect encodes errors in the type signature
function processPayment(
  amount: PositiveNumber
): Effect.Effect<PaymentResult, PaymentError | NetworkError> { ... }

Strength: Problems caught here never reach runtime. Zero false negatives. Zero runtime cost.

Cost: Requires upfront type design. May need branded types, Effect, or discriminated unions.


Level 2: Library Primitives (Very Good)

Wrap the dangerous operation in a safe primitive. Everyone uses the primitive. Nobody touches the raw operation.

When to use: Multiple places need to do the same dangerous thing (DB queries, API calls, validation). The risk is in the raw operation, not the data shape.

Examples:

// BAD: every query manually adds tenant scoping
const users = await db.query('SELECT * FROM users WHERE tenant_id = ?', [tenantId]);

// GOOD: safe primitive that always scopes
const users = await tenantDb.query(tenantId, 'SELECT * FROM users');
// tenantDb.query internally guarantees tenant_id is always applied
// BAD: every API handler manually validates input
app.post('/users', async (req, res) => {
  if (!req.body.email) return res.status(400).send('Missing email');
  // ... 20 more manual checks
});

// GOOD: validated endpoint primitive
app.post('/users', validated(CreateUserSchema), async (req, res) => {
  // req.body is already validated and typed
});

Strength: Centralizes risk. One correct implementation, used everywhere. Easy to audit.

Cost: Requires designing the primitive. Must be ergonomic enough that agents prefer it over the raw operation.


Level 3: Runtime Invariants (Good)

Guard at system boundaries with runtime checks. Use Zod schemas, Effect Refined types, or explicit assertions.

When to use: The constraint cannot be expressed in types alone (e.g., “this number must be positive,” “this string must be a valid email,” “this array must have at least one element”).

Examples:

// Zod schema at API boundary
const CreateUserInput = z.object({
  email: z.string().email(),
  age: z.number().int().positive().max(150),
  role: z.enum(['admin', 'user', 'viewer']),
});

// Effect refined type for domain logic
const PositiveAmount = Schema.Number.pipe(
  Schema.positive({ message: () => 'Amount must be positive' })
);

Strength: Catches constraint violations at runtime boundaries. Clear error messages. Self-documenting.

Cost: Runtime overhead (small). Requires discipline to place guards at every boundary.


Level 4: Tests (Good)

Verify behavior mechanically. Especially effective for property-based tests that cover entire classes of bugs.

When to use: The constraint is behavioral, not structural. “This function must always return sorted output.” “This endpoint must never return more than 100 items.”

Udemy Bestseller

Learn Prompt Engineering

My O'Reilly book adapted for hands-on learning. Build production-ready prompts with practical exercises.

4.5/5 rating
306,000+ learners
View Course

Examples:

// Property test: sorting invariant
it.prop([fc.array(fc.integer())], (arr) => {
  const sorted = mySort(arr);
  for (let i = 1; i < sorted.length; i++) {
    expect(sorted[i]).toBeGreaterThanOrEqual(sorted[i - 1]);
  }
});

// Regression test: specific bug that was found and fixed
it('does not double-charge when payment webhook retries', async () => {
  await processWebhook(duplicateEvent);
  await processWebhook(duplicateEvent);
  expect(await getChargeCount(userId)).toBe(1);
});

Strength: Catches behavioral bugs. Documents expected behavior. Prevents regressions.

Cost: Tests verify behavior, they do not prevent construction of invalid states. A test catches the bug after it is written.


Level 5: ESLint / Lint Rules (Structural Only)

Ban structural patterns via static analysis. ESLint is the right tool when the constraint is about code structure, not behavior.

When to use: The problem is a structural anti-pattern that agents keep generating. Banned imports. Forbidden function calls. Required patterns.

Good uses for ESLint:

Rule What It Prevents
No direct DB imports in UI Layer violations
No floating promises Unhandled async errors
Required Effect error channel Silent failures
Banned console.log in production Debug noise
Required as const on config objects Widened types

Bad uses for ESLint:

Rule Why It’s Wrong
“User must be active to bill” Domain logic, belongs in types/primitives
“Query must include tenant_id” Data constraint, belongs in a primitive
“This must be idempotent” Behavioral, belongs in tests
“Response must be under 100ms” Operational, belongs in monitoring

Strength: Catches pattern violations before commit. Fast feedback.

Cost: Cannot express semantic or behavioral constraints. Agents will learn to route around lint rules mechanically (reformatting to satisfy the rule without understanding the intent).


The Three Strikes Rule

Do not over-invest in prevention on the first occurrence. Use progressive escalation.

Strike 1: Fix it locally.
           Just fix the code. Note it happened.

Strike 2: Add a type or primitive.
           The same class of bug appeared twice.
           Invest in a structural fix.

Strike 3: Lint it out of existence.
           Three times means it's a pattern.
           Ban the anti-pattern via lint.
           Point the lint error at the blessed primitive.

This prevents premature abstraction. Not every bug needs a lint rule. Most bugs need a local fix and a type improvement.


Rule Proposal Template

Every new lint rule should be documented:

## Rule: no-direct-db-query

### Problem it prevents
Raw database queries bypass tenant scoping, risking data leakage.

### Example bad code
```ts
const users = await db.query('SELECT * FROM users');

Example good code

const users = await tenantDb.query(tenantId, 'SELECT * FROM users');

Why lint (not types/tests)?

The type system cannot distinguish raw DB calls from safe ones.
A test would only catch specific instances.
A lint rule bans the pattern entirely.

How to migrate existing code

Search for db.query( outside of tenantDb.ts. Replace with tenantDb.query(tenantId, ...).

How to measure success

Recurrence rate of tenant scoping bugs should drop to zero within 2 cycles.


---

## Rules Must Pay Rent

Every 2 weeks, review lint rules:

1. **Does this rule still fire?** If it hasn't triggered in 4 weeks, consider removing it.
2. **Does this rule cause noise?** If agents spend time working around it more than fixing real issues, the rule is too broad.
3. **Is there a better layer?** If a type or primitive now exists that makes the rule redundant, delete the rule.
4. **Has the recurrence rate dropped?** If the same class of issue still appears despite the rule, the rule is not effective. Escalate to a higher layer.

Rules that do not earn their keep get deleted. A codebase with 200 lint rules and no convergence is worse than one with 20 rules that actually bite.

---

## Measuring Success

Track recurrence rate per issue class:

```typescript
interface IssueClass {
  class: string;           // e.g., "tenant-scope-missing"
  firstOccurrence: string; // date
  occurrences: number;     // total times this class appeared
  preventionLayer: 'type' | 'primitive' | 'invariant' | 'test' | 'lint';
  preventionDate: string;  // when prevention was added
  recurrenceAfterPrevention: number; // times it reappeared after fix
}

Healthy signal: recurrenceAfterPrevention drops to zero after adding prevention.

Unhealthy signal: recurrenceAfterPrevention stays nonzero. The prevention layer is wrong or too weak. Escalate to a higher layer.


Key Insight

ESLint is not a dumping ground for domain rules. It is the last resort for structural patterns that survived types, primitives, invariants, and tests. Every lint rule should point at a blessed alternative. Every rule must earn its keep.


Related

Topics
ConstraintsEffectEscalationEslintInvariantsLibrary PrimitivesLint RulesPreventionTestingType Safety

More Insights

Cover Image for Own Your Control Plane

Own Your Control Plane

If you use someone else’s task manager, you inherit all of their abstractions. In a world where LLMs make software a solved problem, the cost of ownership has flipped.

James Phoenix
James Phoenix
Cover Image for Indexed PRD and Design Doc Strategy

Indexed PRD and Design Doc Strategy

A documentation-driven development pattern where a single `index.md` links all PRDs and design documents, creating navigable context for both humans and AI agents.

James Phoenix
James Phoenix