Hyprland experience click to change the experience
Chapitre 4

The context trap

From over-engineering to a constitution.

Period : spring 2025 to summer 2025

Introduction: the “prompt engineering” red herring

As my projects grow, I saturate the context window: the models forget rules, invent patterns, break the architecture. Engineer’s reflex, I want to take back control. This chapter is an archive of exploration: some solutions are already outdated, but the problem stays the same: give an agent the right context, at the right time, without flooding the context window.

That’s where the L0/L1/L2 idea comes from. At first, a naming convention. Quickly, the ambition grows: a rules kernel, plus context modules loaded on demand.

Phase 1: the kernel (L0 / L1 / L2)

I go with a layered architecture: a kernel always loaded, then contexts that get more precise as the task demands. In a codebase, that’s already how we navigate: overview, module, file, detail. I apply the same logic to LLM context.

I don’t want to dump everything into the starting prompt. I want a system that starts light, then drops down through the layers when the task calls for it.

  • L0: the bootstrap. A master file (L0.md) loaded permanently, with global rules, complexity thresholds, and a routing table. Example: if the task mentions auth, the system maps that signal to the L1 authentication context.
  • L1: domain constraints. Each file describes what to never do, the patterns to follow, the useful playbooks, and the neighboring contexts to load if the task overflows.
  • L2: the implementation detail. Specs, ADRs, long notes, detailed guides: the bottom of the drawer. I don’t load it by default, only when I need to understand the why or touch a specific area.

It works, but it’s too heavy. Every interaction starts by loading several thousand tokens before even handling the request. Back then, with context windows much narrower than today’s, this isn’t a detail: I’m wasting part of the budget just to initialize the system.

Request arrives
Phase 1: Kernel L0/L1/L2 architecture

Kernel example (L0.md):

# L0 KERNEL (ROOT CONTEXT)

## MISSION

Act as a senior software architect and lead developer. Your goal is to produce maintainable, scalable, and secure code following the project's established patterns. You must orchestrate the loading of specialized contexts (L1) based on the task at hand.

## CRITICAL RULES (THE "NEVER" LIST)

1.  **NEVER** commit secrets or credentials.
2.  **NEVER** bypass the established hexagonal architecture layers.
3.  **NEVER** introduce new dependencies without explicit approval (ADR required).
4.  **NEVER** write "spaghetti code" (high cyclomatic complexity).
5.  **NEVER** ignore linter or type-checker errors.

## L1 CONTEXT MAP (ROUTING TABLE)

| Priority | Pattern (Regex) | L1 Context File | Description | Load Strategy |
| :------- | :-------------- | :-------------- | :---------- | :------------ | --------------- | ------------------------------ | ----------------------------------- | ----------------------------------- | ---- |
| 1        | `/\b(auth       | login           | signup      | session       | jwt             | oauth)\b/i`                    | `L1/Auth.md`                        | Authentication & Security protocols | AUTO |
| 2        | `/\b(db         | database        | prisma      | drizzle       | sql             | migration)\b/i`                | `L1/Database.md`                    | Data persistence & schema rules     | AUTO |
| 3        | `/\b(api        | endpoint        | route       | controller    | trpc            | graphql)\b/i`                  | `L1/API.md`                         | API design & interface standards    | AUTO |
| 4        | `/\b(ui         | component       | tailwind    | css           | front)\b/i`     | `L1/UI.md`                     | User Interface components & styling | AUTO                                |
| 5        | `/\b(test       | spec            | e2e         | mock)\b/i`    | `L1/Testing.md` | Testing strategies & standards | AUTO                                |

## OPERATING PROCEDURE

1.  **Analyze Task:** Read the user's prompt.
2.  **Scan for Patterns:** Match prompt content against the `L1 CONTEXT MAP` regex patterns.
3.  **Load L1 Contexts:** For each match, load the corresponding `L1` file.
    - _NOTE:_ L1 contexts provide domain-specific constraints and patterns.
4.  **Execute:** Perform the task, adhering strictly to L0 rules AND the loaded L1 rules.
5.  **Consult L2 (Optional):** If deep clarification is needed on a specific decision, consult the referenced L2 specs (ADRs) mentioned in the L1 files.

Phase 2: the compiler trap (Dynamic Context Language)

Natural language stays too fuzzy for my taste. I swing to the other extreme: treat context as code. That becomes DCL (Dynamic Context Language): a YAML grammar with four operators (@context, !constraints, ~procedures, &triggers), typed domains, schemas, fixtures, tests.

Around it, a full pipeline: parser, validator, compiler (Source → AST → prompt), trigger engine. The same source file can produce a Claude prompt, a GPT prompt, or a minimal version.

@context[domain:auth]
  !constraints:
    never: [commit_secrets, raw_sql]
  ~procedures:
    add_endpoint: [validate, authorize, audit]

After a while, the obvious hits me: I’m reinventing the wheel. LLMs already read natural language. Forcing them to read homemade pseudo-code adds friction, and the structure ends up costing more than the fuzziness it was meant to fix. Over-engineering.

Diagram comparing a simple-rules path with a private-notation path: the notation goes through a toolchain, adds a writing and decoding cost, then yields an uncertain gain for the model.
DCL: a private notation can structure context, but it adds a writing and decoding cost.
Enlarged figure

DCL example:

# ==========================================
# DCL (Dynamic Context Language) - Example
# ==========================================
# Domain: Authentication Module
# ==========================================

# Defines the global context of the domain
@context[domain:auth]:
  # Strict constraints (the AI must NEVER do this)
  !constraints:
    - never: [commit_secrets, raw_sql_queries]
    - enforce: [use_orm, secure_password_hashing]

  # Necessary dependencies for this context
  ^dependencies:
    - lib: [bcrypt, jsonwebtoken]
    - service: [user_service, email_service]

  # Procedures and patterns to follow
  ~procedures:
    # Pattern for user signup
    user_signup:
      - step: validate_input
        desc: "Check password complexity and email format."
      - step: hash_password
        desc: "Use bcrypt for hashing. Never store in clear text."
      - step: create_user
        desc: "Call user_service for database creation."
      - step: generate_token
        desc: "Generate a JWT for the session."
      - step: send_welcome_email
        desc: "Call email_service."

    # Pattern for login
    user_login:
      - step: find_user
        desc: "Search user by email."
      - step: verify_password
        desc: "Compare hash with bcrypt."
      - step: generate_token
        desc: "Generate a new JWT."

  # Expected data models
  #schema:
    User:
      - id: [uuid, pk]
      - email: [string, unique]
      - password_hash: [string]
      - created_at: [datetime]

Phase 3: files declare their own context

So I remove the central kernel. No more master file expected to know in advance what context to load. Here, the files announce which contexts they depend on, with tags that act almost like imports.

// @L1: auth, api

I call it HeadsAndTails: instead of reading the whole file, the scanner only looks at the head and tail, where tags can live. The idea is simple: discover the context before loading the detail.

The gain isn’t only about tokens. The real win is removing the central bottleneck. Context is no longer decided by a global router; it emerges from the proximity between the task, the files touched, and the tags declared.

Diagram showing a file read with context tags at head and tail: the detected tags pass through an L0 map, trigger L1 reads, then feed the model.
Heads and tails: reading a file can trigger L1 context loading through L0 rules.
Enlarged figure

Example file with context tags:

// ==========================================
// FILE: src/modules/user/core/application/use-cases/CreateUser.ts
//
// @L1: auth, database, domain-events
// @architect: hexagonal/use-case
// ==========================================

import { User } from "../../domain/User";
import { IUserRepository } from "../../ports/IUserRepository";
// ... other imports

export class CreateUserUseCase {
  constructor(
    private readonly userRepo: IUserRepository,
    // ...
  ) {}

  async execute(command: CreateUserCommand): Promise<Result<User>> {
    // Business logic...
    // The AI knows it must respect "auth" and "database" rules
    // because they were declared in the header.
  }
}

// ==========================================
// @security: low-risk
// ==========================================

Phase 4: the MCP detour

After the tags, I try something else: stop dumping everything into the prompt. I no longer want to decide in advance everything the agent has to know. I want to give it a stable entry point, plus tools to go fetch the rest.

The entry point is AI.md: a few rules, a few conventions, and above all the idea that context can be requested at the moment it’s useful. The details live in context.ai files.

MCP becomes the access door. If the task touches auth, media, or database, the agent can search for the matching context, read the file, then work with those rules loaded on demand.

On paper, this is more flexible than the kernel, less rigid than DCL, less invasive than tags in every file. But the trap comes back in another shape: I gain on-demand loading, and I pay in tool surface. Tools to define, a server to keep alive, tool definitions (the tool instructions and call schema added to context before the request is even handled) injected into the initial context. Once I start measuring what is spent on those definitions alone, I see I’ve mostly moved the problem.

On paper, this is more flexible than the kernel, less rigid than DCL, less invasive than tags in every file. But each MCP tool ships with its description, parameters, and call schema. Those tool definitions (the tool instructions and call schema added to context before the request is even handled) enter the context window as soon as the session starts. Even if the agent never calls the tool, the budget is already spent. Once I start measuring that cost, I see the issue is not only the tool call, but the tool menu loaded upfront.

Diagram showing an agent calling an MCP tool: the MCP layer finds context.ai, reads the paths, loads the context files, aggregates Markdown, and returns a tool result to the model.
MCP: context loading moves into a local tool layer, but the tool definitions already cost at startup.
Enlarged figure

Context file example (context.ai):

# ==========================================
# LOCATION: src/modules/payment/context.ai
# ==========================================

# Metadata for the agent
meta:
  domain: payment
  description: "Critical rules for payment processing."

# Conditional loading rules
# The agent evaluates these rules before deciding to load this context.
load_if:
  - rule: "Task involves money, transactions, or stripe."
    reason: "High risk domain."
  - rule: "User explicitly mentions 'payment' or 'checkout'."
    reason: "Explicit request."
  - rule: "Files being edited are in 'src/modules/payment/'."
    reason: "Proximity."

# The context itself, loaded only if a rule is met.
context:
  # Critical constraints
  constraints:
    - "NEVER log full credit card numbers or CVV."
    - "MUST use the 'PaymentGatewayPort' for all external calls."
    - "All transactions MUST be wrapped in a database transaction."

  # Implementation patterns
  patterns:
    - name: "Idempotency"
      description: "Ensure payment requests are idempotent using a unique key."

Simulation: the agent with custom MCP in action

User: “I need to refund a payment. It’s sensitive.”

AI (Thought): “Critical task in payment domain. I won’t code anything without checking the rules.” AI (MCP Action): fs_scanner.scan("src/modules/payment", look_for=["context.ai"]) AI (Result): “File context.ai found.” AI (MCP Action): context_loader.load("src/modules/payment/context.ai") AI (Thought): “Context loaded. Critical rules: use PaymentGatewayPort, wrap in DB transaction. Ok, I can start the plan.”

Phase 5: back to the essentials (the constitution)

At this point, AI.md is still mostly serving as an index pointing to external context. The shift is to strip it of that ambition: it becomes a constitution. A short, stable, versioned base that agents read before acting.

Diagram showing Kernel, DCL, Tags, and MCP prototypes around a central Constitution card with Rules, Rationale, and Validate sections, marked with a Maintenance stamp.
Back to the essentials: the older mechanisms stay as prototypes, but the short constitution becomes the base to maintain.
Enlarged figure

In practice, it’s not always a single file. There’s AI.md as the shared source, aliases when a tool expects another name (CLAUDE.md, AGENTS.md, sometimes CURSOR.md or GEMINI.md), and more local rules when the project calls for them.

I’m already experimenting with .cursor/rules/, and with CLAUDE.md files scattered through the tree. The idea is sound: keep rules close to the code area they concern. But on the Claude Code side, that local loading stays fragile in my tests. I drop nested CLAUDE.md files all over the place, hoping the agent picks them up as it walks down the tree. In practice, they rarely show up at the right moment. Part of my stubbornness comes from there.

Update (December 10, 2025)

A few weeks after this chapter first went live, Claude Code 2.0.64 adds support for .claude/rules/. I find that intuition picked up in a more official form: modular rules, scoping by path.

My homemade tinkering was heading in that direction; I can finally drop part of the in-house machinery.

What AI.md contains

AI.md doesn’t try to explain everything. It pins down what must not drift.

First I drop in the guardrails: the NEVERs, the MUSTs, the limits the agent must not cross even if it thinks it’s saving time.

- NEVER throw in domain/application code
- NEVER violate layer boundaries
- MUST validate touched areas before committing

I also drop in the architecture rules. It’s a local law of the project, not a style preference: which layers can talk to each other, which dependencies are off-limits, which commands validate the result.

And above all, I add the why. A dry rule gets worked around easily. A rule that comes with its reason holds up better in context: if the agent understands that we decouple the domain from technology to keep the core testable and portable, it respects the constraint better.

A shared source, several entry points

The goal isn’t a sacred file, it’s avoiding divergent copies. When a tool expects CLAUDE.md, AGENTS.md, or another name, I point it at the same source instead of rewriting the same rules in several places.

# Back to basics: simplicity
$ ls -l .ai/
-rw-r--r--  1 user  staff  4096 Nov 21 AI.md       # The constitution (single source)
# Aliases for tool compatibility. They all point to the same file.
lrwxr-xr-x  1 user  staff     5 Nov 21 CLAUDE.md -> AI.md
lrwxr-xr-x  1 user  staff     5 Nov 21 CURSOR.md -> AI.md
lrwxr-xr-x  1 user  staff     5 Nov 21 GEMINI.md -> AI.md

Constitution extract (AI.md):

# Constitution

## TL;DR (The "NEVER" List)

**NEVER:**

- Mute lint/type errors (fix root causes)
- Violate layer boundaries (core→npm, application→infrastructure)
- Throw in domain/application (return `Result<T, E>`)

## Principles & Rationale (Drift Prevention)

**Why these rules exist:**

1. **Architecture**: We decouple the _core domain_ from _technology_...
   ...

Conclusion: the lesson of obsolescence

This exploration taught me one simple thing: context isn’t free, even when you load it smartly.

The kernel costs at startup. DCL costs in language to maintain. Tags cost in file discipline. MCP costs in tool surface. Even the constitution costs attention: you have to write it, keep it honest, prevent it from turning into a junk drawer.

I stop hunting for the right router. The subject becomes simpler: keep the minimum of stable context that lets the agent act without breaking everything.

The rest has to be verified after the fact. Not on trust, but by system: types, lint, tests, architecture rules, validators.

In the journal, I have a chapter dedicated to the guardrails. Before that, I go through orchestration: organizing the agents’ work between themselves.

I cleaned up and published the code from this exploration as open source: ai-context-layers. It’s an educational archive of Phases 1 to 4: the DCL compiler and the HeadsAndTails tags are readable if you want to see how it failed.