Making Invalid States Impossible: Sculpting the LLM Computation Graph

James Phoenix
James Phoenix

The Problem

When AI coding agents work on your codebase, they have tremendous power – and with that power comes the risk of reaching invalid states.

Consider these scenarios:

Scenario 1: Test Fixing Gone Wrong

You ask the LLM to “fix the failing authentication tests.” The LLM:

  1. Identifies the test expecting user.role === 'admin'
  2. Sees the service returns user.role === 'administrator'
  3. Modifies the service code to return 'admin' instead of fixing the test
  4. Breaks production code that depends on 'administrator'

Root cause: The LLM had unrestricted access to both test and service code. It chose the “easier” path of modifying the service.

Scenario 2: Type Safety Bypass

You ask the LLM to “make this TypeScript code compile.” The LLM:

  1. Identifies type mismatch: string provided where User expected
  2. Adds as User type assertion to bypass the type system
  3. Ships code with runtime type errors

Root cause: The LLM had access to type assertions, which bypass type safety.

Scenario 3: Architectural Pattern Violation

You ask the LLM to “add logging to this function.” The LLM:

  1. Identifies need for logging
  2. Adds console.log() instead of using the structured logger
  3. Violates established logging patterns
  4. Logs don’t appear in production monitoring

Root cause: The LLM had access to console.log, which should be forbidden.

The Common Pattern

In all cases, we’re validating correct behavior instead of preventing incorrect behavior:

// Traditional validation approach
if (modifiedFile.startsWith('src/') && mode === 'test-fixing') {
  throw new Error('Cannot modify source files during test fixing');
}

This catches the error after the LLM has done the work. The LLM wasted tokens, you wasted time, and now you need to retry.

Better approach: Make it impossible for the LLM to modify source files during test fixing.

The Solution: Sculpting the Computation Graph

Core Principle

Prevent what you don’t want, not just validate what you do want.

Inspired by type systems that catch errors at compile-time instead of runtime, we sculpt the computation graph to eliminate entire classes of bugs:

// Runtime validation (traditional)
function divide(a: number, b: number): number {
  if (b === 0) throw new Error('Division by zero');
  return a / b;
}

// Compile-time prevention (type system)
type NonZeroNumber = number & { __brand: 'NonZero' };

function divide(a: number, b: NonZeroNumber): number {
  return a / b; // b cannot be zero
}

Similarly for LLMs:

// Validation approach (catches errors after they happen)
if (file.modified && !allowedFiles.includes(file.path)) {
  rollback();
  throw new Error('File not allowed');
}

// Prevention approach (makes errors impossible)
const llm = createRestrictedLLM({
  writableFiles: allowedFiles,
  // LLM physically cannot write to other files
});

The Type System Analogy

Type systems are computation graph sculptors:

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
// Without types: any operation is possible
function process(data) {
  return data.toUpperCase(); // Runtime error if data is a number
}

// With types: invalid operations are impossible
function process(data: string) {
  return data.toUpperCase(); // Compile-time guarantee
}

Type systems reduce the entropy of possible program states by eliminating invalid states at compile-time.

Similarly, we can reduce the entropy of LLM-generated code by restricting the state space:

// Without restrictions: LLM can reach any state
llm.task('Fix tests'); // Could modify services, tests, config, etc.

// With restrictions: LLM can only reach valid states
llm.withRestrictions({
  mode: 'test-fixing',
  writableFiles: ['tests/**/*.ts'],
}).task('Fix tests'); // Can only modify tests

Implementation Patterns

Pattern 1: Mode-Based File Access Restrictions

Problem: LLM modifies unrelated files during focused tasks.

Solution: Define modes with explicit file access rules.

// Mode definitions
const MODES = {
  'test-fixing': {
    writableGlobs: ['tests/**/*.ts', '**/*.spec.ts', '**/*.test.ts'],
    readableGlobs: ['**/*'],
    rationale: 'Tests should be fixed by modifying tests, not services',
  },
  'service-development': {
    writableGlobs: ['src/**/*.ts', 'packages/**/src/**/*.ts'],
    readableGlobs: ['**/*'],
    rationale: 'Service development should not modify tests',
  },
  'code-review': {
    writableGlobs: [], // Read-only
    readableGlobs: ['**/*'],
    rationale: 'Reviews should not modify code',
  },
};

// Enforcement mechanism
function validateFileAccess(filePath: string, mode: string, operation: 'read' | 'write'): void {
  const modeConfig = MODES[mode];
  if (!modeConfig) throw new Error(`Unknown mode: ${mode}`);

  const globs = operation === 'write' ? modeConfig.writableGlobs : modeConfig.readableGlobs;
  const isAllowed = globs.some(glob => minimatch(filePath, glob));

  if (!isAllowed) {
    throw new Error(
      `Cannot ${operation} ${filePath} in ${mode} mode. ${modeConfig.rationale}`
    );
  }
}

// Integration with LLM tools
function createRestrictedFileWriter(mode: string) {
  return {
    writeFile: async (path: string, content: string) => {
      validateFileAccess(path, mode, 'write');
      await fs.writeFile(path, content);
    },
  };
}

Claude Code Integration: Use hooks to enforce restrictions.

// .claude/hooks/pre-write.ts
import { minimatch } from 'minimatch';

const MODE = process.env.CLAUDE_MODE || 'default';

const RESTRICTIONS = {
  'test-fixing': ['tests/**/*.ts', '**/*.spec.ts'],
  'service-development': ['src/**/*.ts'],
};

export default function preWriteHook(filePath: string) {
  const allowedGlobs = RESTRICTIONS[MODE];
  if (!allowedGlobs) return; // No restrictions for this mode

  const isAllowed = allowedGlobs.some(glob => minimatch(filePath, glob));
  if (!isAllowed) {
    throw new Error(
      `Cannot modify ${filePath} in ${MODE} mode. Only ${allowedGlobs.join(', ')} allowed.`
    );
  }
}

Usage:

# Set mode before asking Claude to fix tests
export CLAUDE_MODE=test-fixing

# Claude can now only modify test files
# Attempts to modify service files will fail immediately

Pattern 2: Custom ESLint Rules for Pattern Enforcement

Problem: LLM uses forbidden patterns (type assertions, console.log, etc.).

Solution: Create custom ESLint rules that fail on forbidden patterns.

// eslint-rules/no-as-type-assertions.ts
import { ESLintUtils } from '@typescript-eslint/utils';

const createRule = ESLintUtils.RuleCreator(
  name => `https://docs.example.com/eslint-rules/${name}`
);

export const noAsTypeAssertions = createRule({
  name: 'no-as-type-assertions',
  meta: {
    type: 'problem',
    docs: {
      description: 'Disallow type assertions using "as" keyword',
      recommended: 'error',
    },
    messages: {
      noAs: 'Type assertions with "as" bypass type safety. Use type narrowing (type guards, discriminated unions) instead.',
    },
    schema: [],
  },
  defaultOptions: [],
  create(context) {
    return {
      TSAsExpression(node) {
        context.report({
          node,
          messageId: 'noAs',
        });
      },
    };
  },
});
// eslint-rules/no-console-log.ts
export const noConsoleLog = createRule({
  name: 'no-console-log',
  meta: {
    type: 'problem',
    docs: {
      description: 'Disallow console.log in favor of structured logging',
      recommended: 'error',
    },
    messages: {
      noConsole: 'Use logger.info() instead of console.log() for structured logging.',
    },
    schema: [],
  },
  defaultOptions: [],
  create(context) {
    return {
      MemberExpression(node) {
        if (
          node.object.type === 'Identifier' &&
          node.object.name === 'console' &&
          node.property.type === 'Identifier' &&
          ['log', 'info', 'warn', 'error'].includes(node.property.name)
        ) {
          context.report({
            node,
            messageId: 'noConsole',
          });
        }
      },
    };
  },
});

Configuration:

// .eslintrc.json
{
  "plugins": ["@custom/eslint-rules"],
  "rules": {
    "@custom/no-as-type-assertions": "error",
    "@custom/no-console-log": "error",
    "@custom/no-class-definitions": "error"
  }
}

Result: LLM cannot generate code using forbidden patterns. ESLint fails immediately, forcing regeneration with correct patterns.

Pattern 3: TypeScript Configuration for Type Safety

Problem: LLM uses any types or disables type checking.

Solution: Configure TypeScript to forbid escape hatches.

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true,
    "allowUnusedLabels": false,
    "allowUnreachableCode": false
  }
}

ESLint rules (to catch what TypeScript misses):

{
  "rules": {
    "@typescript-eslint/no-explicit-any": "error",
    "@typescript-eslint/no-unsafe-assignment": "error",
    "@typescript-eslint/no-unsafe-member-access": "error",
    "@typescript-eslint/no-unsafe-call": "error",
    "@typescript-eslint/no-unsafe-return": "error",
    "@typescript-eslint/ban-ts-comment": "error"
  }
}

Result: LLM cannot bypass type safety. All code must be fully typed.

Pattern 4: Architectural Boundary Enforcement

Problem: LLM violates layered architecture (e.g., UI importing from database layer).

Solution: Use import restrictions to enforce boundaries.

// eslint-rules/no-cross-layer-imports.ts
export const noCrossLayerImports = createRule({
  name: 'no-cross-layer-imports',
  meta: {
    type: 'problem',
    docs: {
      description: 'Enforce layered architecture boundaries',
      recommended: 'error',
    },
    messages: {
      invalidImport: '{{from}} cannot import from {{to}}. Violates layered architecture.',
    },
    schema: [],
  },
  defaultOptions: [],
  create(context) {
    const filename = context.getFilename();
    const layer = getLayer(filename);

    return {
      ImportDeclaration(node) {
        const importPath = node.source.value;
        const targetLayer = getLayer(importPath);

        if (!isAllowedImport(layer, targetLayer)) {
          context.report({
            node,
            messageId: 'invalidImport',
            data: {
              from: layer,
              to: targetLayer,
            },
          });
        }
      },
    };
  },
});

function getLayer(path: string): string {
  if (path.includes('/ui/')) return 'ui';
  if (path.includes('/api/')) return 'api';
  if (path.includes('/services/')) return 'services';
  if (path.includes('/database/')) return 'database';
  return 'unknown';
}

function isAllowedImport(from: string, to: string): boolean {
  const rules = {
    ui: ['ui', 'api'], // UI can import from UI and API
    api: ['api', 'services'], // API can import from API and services
    services: ['services', 'database'], // Services can import from services and database
    database: ['database'], // Database can only import from database
  };

  return rules[from]?.includes(to) ?? false;
}

Result: LLM cannot create cross-layer dependencies. Architecture is enforced.

Pattern 5: Read-Only Review Mode

Problem: Code review LLMs modify code instead of generating comments.

Solution: Remove write permissions entirely.

// review-mode.ts
interface ReviewContext {
  mode: 'review';
  permissions: {
    read: boolean;
    write: boolean;
    execute: boolean;
  };
}

function createReviewLLM(): ReviewContext {
  return {
    mode: 'review',
    permissions: {
      read: true,
      write: false, // Cannot modify files
      execute: false, // Cannot run commands
    },
  };
}

// Tool implementation
const tools = {
  readFile: (path: string) => fs.readFile(path, 'utf-8'),
  writeFile: (path: string, content: string) => {
    if (!context.permissions.write) {
      throw new Error('Write operations not allowed in review mode');
    }
    fs.writeFile(path, content);
  },
  generateReviewComment: (file: string, line: number, message: string) => {
    // Always allowed
    comments.push({ file, line, message });
  },
};

Result: Review LLM cannot modify code, only generate review comments.

Real-World Examples

Example 1: Test Fixing Workflow

Scenario: Failing integration tests after API change.

Traditional approach (validation):

$ claude "Fix the failing authentication tests"

# Claude modifies both tests AND service code
# Validation catches service modification
# Rollback required
# Retry with explicit instruction: "Only modify tests"
# Retry with more explicit instruction: "Do NOT modify service code"
# Finally succeeds

Sculpted approach (prevention):

$ export CLAUDE_MODE=test-fixing
$ claude "Fix the failing authentication tests"

# Claude attempts to modify service code
# Pre-write hook blocks immediately: "Cannot modify src/ in test-fixing mode"
# Claude adjusts strategy, modifies only tests
# Success on first try

Result: Faster iteration, no wasted tokens, clearer feedback.

Example 2: Type Safety Enforcement

Traditional approach (validation):

// LLM generates code with type assertions
const user = getUserData() as User;

// Code review catches it
// Request regeneration without type assertions
// LLM generates:
const user = getUserData() as any as User; // Double assertion!

// Another review cycle...

Sculpted approach (prevention):

// ESLint rule: @custom/no-as-type-assertions

// LLM generates:
const user = getUserData() as User;

// ESLint fails immediately
// LLM sees error message: "Use type narrowing instead"
// LLM regenerates with proper type guard:
const data = getUserData();
if (!isUser(data)) {
  throw new Error('Invalid user data');
}
const user: User = data;

// ESLint passes
// Success on second try

Result: Type safety enforced automatically, no manual review needed.

Example 3: Logging Pattern Enforcement

Traditional approach:

// LLM adds logging
function processPayment(amount: number) {
  console.log('Processing payment:', amount);
  // ...
}

// Code review: "Use structured logger"
// LLM regenerates:
function processPayment(amount: number) {
  console.info('Processing payment:', amount); // Still wrong!
  // ...
}

Sculpted approach:

// ESLint rule: @custom/no-console-log

// LLM generates:
function processPayment(amount: number) {
  console.log('Processing payment:', amount);
  // ...
}

// ESLint fails: "Use logger.info() instead"
// LLM regenerates:
import { logger } from '@/lib/logger';

function processPayment(amount: number) {
  logger.info('Processing payment', { amount });
  // ...
}

// ESLint passes

Result: Consistent logging patterns, enforced automatically.

Benefits

1. Fail Fast

Before: LLM does work → validation fails → rollback → retry → validation passes

After: LLM attempts invalid action → blocked immediately → LLM adjusts strategy → success

Time saved: 50-70% reduction in iteration cycles

2. Clearer Intent

Restrictions communicate what’s not allowed:

// Validation (unclear intent)
if (file.path.startsWith('src/')) {
  throw new Error('Cannot modify source files');
}
// "Why not? What should I modify instead?"

// Prevention (clear intent)
const mode = {
  writableGlobs: ['tests/**/*.ts'],
  rationale: 'Tests should be fixed by modifying tests, not services',
};
// "I should modify tests, not services"

3. Enforces Patterns

Invalid patterns become impossible:

// Without restrictions: possible but wrong
const user = data as User;

// With restrictions: impossible
// ESLint error: "Type assertions not allowed"
// LLM must use type guards

4. Reduces Human Review

Automated enforcement reduces manual code review:

// Before: manual review required
"Did the LLM use type assertions?"Manual check
"Did the LLM modify service code?"Manual check
"Did the LLM use console.log?"Manual check

// After: automated enforcement
"Did the LLM use type assertions?"ESLint checked
"Did the LLM modify service code?"Hook checked
"Did the LLM use console.log?"ESLint checked

5. Prevents Entire Classes of Bugs

Like type systems:

// Type system prevents:
- null reference errors
- type mismatches
- undefined function calls

// Computation graph sculpting prevents:
- Cross-layer imports
- Pattern violations
- Workflow escapes

Trade-offs

Con 1: Less Flexibility

Sometimes you need to break rules:

// Legitimate use of type assertion
const element = document.getElementById('app') as HTMLDivElement;

// But rule forbids ALL type assertions
// ESLint error: "Type assertions not allowed"

Mitigation: Use escape hatches with justification:

// eslint-disable-next-line @custom/no-as-type-assertions -- DOM API requires assertion
const element = document.getElementById('app') as HTMLDivElement;

Con 2: Maintenance Overhead

Rules need updating as patterns evolve:

// Pattern changes from functional to class-based
// Must update ESLint rule: no-class-definitions → allow classes
// Must update documentation
// Must notify team

Mitigation: Version rules with codebase versions:

// v1: no-class-definitions (functional patterns)
// v2: allow-classes (class-based patterns)

Con 3: Complexity

More moving parts:

// Traditional: just the code
src/
  ├── services/
  └── tests/

// Sculpted: code + enforcement
src/
  ├── services/
  └── tests/
eslint-rules/
  ├── no-as-type-assertions.ts
  ├── no-console-log.ts
  └── no-cross-layer-imports.ts
.claude/
  └── hooks/
      └── pre-write.ts

Mitigation: Document all rules in CLAUDE.md:

# CLAUDE.md

## Enforced Rules

### Type Safety
- No type assertions (`as`)
- No `any` types
- Strict null checks

### Architectural Boundaries
- UI cannot import from database
- Services cannot import from UI

### Pattern Enforcement
- No console.log (use logger)
- No classes (use functions)

When to Use

Use when:

  1. Critical patterns that must never be violated

    • Type safety in production code
    • Security patterns (auth, sanitization)
    • Architectural boundaries
  2. Task-specific workflows

    • Test fixing (only modify tests)
    • Code review (read-only access)
    • Refactoring (specific file scope)
  3. Pattern migration

    • Migrating from classes to functions
    • Enforcing new logging patterns
    • Adopting type-safe patterns

Don’t use when:

  1. Exploratory development

    • Prototyping new features
    • Experimenting with patterns
    • Proof-of-concepts
  2. Patterns are unclear

    • Team hasn’t decided on patterns
    • Multiple valid approaches exist
    • Requirements are evolving
  3. Escape hatches needed frequently

    • Rules block legitimate use cases
    • Too many eslint-disable comments
    • Productivity suffers

Implementation Checklist

Phase 1: Identify Critical Patterns

  • Review codebase for architectural patterns
  • Identify patterns that must never be violated
  • Document rationale for each restriction
  • Get team buy-in on restrictions

Phase 2: Implement Rules

  • Create custom ESLint rules for pattern enforcement
  • Configure TypeScript for maximum strictness
  • Implement file access restrictions (hooks, modes)
  • Add architectural boundary checks

Phase 3: Document Restrictions

  • Update CLAUDE.md with all rules and rationale
  • Add examples of correct vs incorrect patterns
  • Document escape hatches and when to use them
  • Create troubleshooting guide for common errors

Phase 4: Test Enforcement

  • Test that LLM cannot violate restrictions
  • Verify error messages are clear and actionable
  • Ensure legitimate use cases still work
  • Measure iteration speed improvement

Phase 5: Iterate

  • Monitor false positives (legitimate code blocked)
  • Adjust rules based on team feedback
  • Add new rules as patterns emerge
  • Remove rules that cause more harm than good

Best Practices

1. Provide Clear Error Messages

Bad:

throw new Error('Invalid operation');

Good:

throw new Error(
  'Cannot modify src/ in test-fixing mode. ' +
  'Tests should be fixed by modifying test files, not service code. ' +
  'Only tests/**/*.ts files are writable in this mode.'
);

2. Document Rationale

Bad:

// Don't use type assertions

Good:

/**
 * Disallow type assertions ("as" keyword)
 *
 * Rationale: Type assertions bypass TypeScript's type system,
 * allowing runtime type errors. Use type guards and discriminated
 * unions for type narrowing instead.
 *
 * Example:
 *
 * // Bad
 * const user = data as User;
 *
 * // Good
 * if (!isUser(data)) throw new Error('Invalid user');
 * const user: User = data;
 */

3. Start with Critical Rules

Don’t add all rules at once:

// Phase 1: Critical safety rules
- no-as-type-assertions
- no-cross-layer-imports

// Phase 2: Pattern enforcement
- no-console-log
- no-class-definitions

// Phase 3: Workflow restrictions
- test-fixing mode
- code-review mode

4. Measure Impact

Track before/after metrics:

interface Metrics {
  avgIterationsPerTask: number;
  timeToCompletion: number;
  patterViolationsInReview: number;
}

// Before
const before: Metrics = {
  avgIterationsPerTask: 4.2,
  timeToCompletion: 18, // minutes
  patterViolationsInReview: 12, // per week
};

// After
const after: Metrics = {
  avgIterationsPerTask: 2.1, // 50% reduction
  timeToCompletion: 9, // 50% reduction
  patterViolationsInReview: 1, // 92% reduction
};

5. Balance Prevention and Flexibility

Not everything should be prevented:

// Prevent: Critical safety issues
- Type safety violations
- Security bypasses
- Architectural violations

// Validate: Style preferences
- Formatting (Prettier handles this)
- Naming conventions (suggestions, not errors)
- Comment style

Conclusion

Making invalid states impossible is more powerful than validating correct states.

By sculpting the computation graph – restricting file access, forbidding patterns, enforcing boundaries – you:

  1. Fail fast: Catch errors at generation time, not review time
  2. Communicate intent: Restrictions explain what’s not allowed
  3. Enforce patterns: Invalid code becomes impossible
  4. Reduce review: Automated enforcement replaces manual checks
  5. Prevent bug classes: Entire categories of errors eliminated

The key insight: Like type systems that catch errors at compile-time instead of runtime, sculpt the LLM’s state space to prevent errors before they happen.

Start small: Identify one critical pattern, add one restriction, measure impact. Then expand.

Remember: Not everything should be prevented. Balance prevention (critical rules) with validation (preferences). Use sculpting for patterns that must never be violated.

Related Concepts

References

Topics
Architecture EnforcementCompile TimeEslintLlm ConstraintsPreventionQuality GatesState ManagementStatic AnalysisType SafetyWorkflow Restrictions

More Insights

Cover Image for Thought Leaders

Thought Leaders

People to follow for compound engineering, context engineering, and AI agent development.

James Phoenix
James Phoenix
Cover Image for Systems Thinking & Observability

Systems Thinking & Observability

Software should be treated as a measurable dynamical system, not as a collection of features.

James Phoenix
James Phoenix