The AI Coding Protocol: How Engineering Teams Should Build Software with Agentic AI

AI coding agents produce syntactically correct, well-architected code that silently returns wrong results. This protocol defines the engineering discipline required to build reliable software with agentic AI - four cardinal rules, systematic countermeasures, and a reusable framework your team can adopt today.

March 15, 2026

AI Agents Write Great Code. That Is the Problem.

Engineering teams everywhere are adopting agentic AI tools - Claude, Cursor, Copilot, and others - to build software faster. The acceleration is real. An AI coding agent can scaffold an entire service in minutes: clean architecture, typed interfaces, proper error handling, comprehensive tests. The code compiles, the tests pass, the PR looks good.

And then it goes to production and silently produces the wrong answer.

This is not a tooling problem. It is not a prompting problem. It is a discipline problem - and it requires an engineering response. The same way teams adopted code review, CI/CD, and testing frameworks as standard practice, teams building with AI agents need a protocol that catches the specific class of failures these tools introduce.

This article defines that protocol. It covers the four non-negotiable rules, the bias patterns to watch for, the countermeasures that work, and a complete framework your team can adopt today. The full protocol is provided at the end - free to fork, customize, and use.

The Failure Patterns You Need to Know

AI agents do not produce random bugs. They produce systematic, repeatable failures rooted in how large language models are trained. Once you see the patterns, you can design against them.

Pattern 1: The Optimism Bias

AI agents are trained on millions of examples of working code - functional, happy-path implementations. This creates a systematic tendency to underweight failure paths. The agent handles the expected case beautifully and treats the unexpected case as an afterthought.

Consider a permission check function in an access control system:

# What the AI agent produces:
def get_user_access_level(user_id, db):
    user = db.get(user_id)
    if user and user.failed_logins > 3:
        return "restricted"
    return "full"  # Missing user = "full access"?

# What correctness requires:
def get_user_access_level(user_id, db):
    user = db.get(user_id)
    if user is None:
        return "denied"  # Cannot grant access to what we cannot verify
    if user.failed_logins > 3:
        return "restricted"
    return "full"

The first version looks complete. Every call returns a value. But a user that does not exist in the database - perhaps a deleted account, perhaps a data migration gap - gets classified as "full access." In any system where that classification drives downstream decisions (granting permissions, unlocking features), this is a silent, compounding failure.

The fix is not more testing. The fix is a rule: unknown inputs must produce restrictive outputs.

Pattern 2: The Completion Bias

AI agents optimize for task completion. There is an inherent pressure to produce output for every input, to close every code path with something reasonable-looking. This is exactly the wrong instinct for systems that handle financial data, medical records, or security decisions.

# Completion bias in action:
def calculate_confidence_score(submission):
    schema = get_validation_schema(submission.type)
    history = get_historical_data(submission.source_id)
    
    if schema and history:
        return full_validation(submission, schema, history)
    elif schema:
        return partial_validation(submission, schema)
    else:
        return 100.0  # "Auto-accept" - nothing to validate against

# What the system actually needs:
def calculate_confidence_score(submission):
    schema = get_validation_schema(submission.type)
    history = get_historical_data(submission.source_id)
    
    if schema and history:
        return full_validation(submission, schema, history)
    elif schema:
        return partial_validation(submission, schema)
    else:
        return ValidationResult(
            score=0.0,
            status="CANNOT_VALIDATE",
            reason="No validation schema found"
        )

The first version handles every input and always returns a number. The agent sees a complete function. But "nothing to validate against" silently becomes "fully validated" - because 100.0 looks like a confidence score, not a default. In any automated pipeline, that means submissions sail through with no verification.

Pattern 3: The Symmetry Bias

This one is subtle and pervasive. AI agents produce aesthetically balanced code. When they encounter a weight table or configuration, they assign middle values to middle concepts. "Skipped" feels like it sits between "pass" and "fail," so it receives a weight of 0.5:

# The symmetry bias in scoring:
STATUS_WEIGHTS = {
    "pass": 1.0,
    "warning": 0.7,
    "skipped": 0.5,  # "Halfway" between pass and fail
    "fail": 0.0,
    "error": 0.2,   # "Slightly above fail"
}

This looks reasonable at first glance. But giving skipped checks half credit means an item where 8 out of 10 checks were skipped - because the source data was entirely missing - scores 40%. That is often high enough to pass automated thresholds. The system accepts an item that was barely examined, and the log shows a respectable-looking score.

The correct weight for "skipped" is always zero. Work that did not execute gets no credit.

Pattern 4: The Demo-Path Bias

AI agents build features by imagining the demo. The happy path is vivid; the failure path is abstract. This leads to test fixtures that always demonstrate success, and error handling that exists syntactically but never gets exercised.

# Demo-path bias in test data:
test_records = [
    {"status": "validated", "score": 95, "check": "full"},
    {"status": "validated", "score": 87, "check": "partial"},
    {"status": "accepted", "score": 100, "check": "full"},
]
# Where are the failures? Missing references? Broken lookups?
# Negative values? Future dates? Unicode in fields?

# Production-ready test data includes adversarial cases:
test_records = [
    {"status": "validated", "score": 95, "check": "full"},
    {"status": "exception", "score": 0, "check": "cannot_run",
     "reason": "Reference data not found"},
    {"status": "exception", "score": 23, "check": "partial",
     "reason": "Field mismatch: expected 500, got 450"},
    {"status": "error", "score": None, "check": None,
     "reason": "External service timeout"},
    {"value": -5000, "status": "flagged",
     "reason": "Negative value"},
]

The Four Cardinal Rules

These are not guidelines. They are invariants that must hold true in every function, every endpoint, and every UI component in any codebase where correctness matters. Think of them as the constitutional rules for AI-assisted development.

Rule 1: Default to Deny

Every scoring system, validation check, access control, and state transition must default to the restrictive outcome.

The test: If all external data disappears - the database returns empty, the API times out, the cache is cold - does the system lock down or open up? If it opens up, the default is wrong.

# Default-to-deny in scoring:
def compute_overall_score(validation_results):
    if not validation_results:
        return 0.0   # Nothing checked = zero confidence
    # ... actual scoring logic

# In access control:
def can_approve(user, record):
    if user.role is None:
        return False  # Unknown role = no access
    if record.organization_id != user.organization_id:
        return False  # Wrong org = no access
    return user.role in APPROVER_ROLES

# In configuration:
def start_app(config):
    if not config.SECRET_KEY:
        raise ValueError("SECRET_KEY must be set")
        # Not: SECRET_KEY = "default-key-for-development"

Rule 2: Absence Is Not Evidence

"Not found" is not the same as "verified clean." "Skipped" is not the same as "passed." "No error returned" is not the same as "succeeded."

Every function that checks for something must have three distinct return paths: found, not-found, and could-not-check. This is three-state thinking, and it is the single most impactful pattern in the entire protocol.

from enum import Enum

class LookupResult(Enum):
    FOUND = "found"           # Checked - data exists
    NOT_FOUND = "not_found"   # Checked - data absent
    UNAVAILABLE = "unavailable" # Could not check

async def lookup_record(record_id, db):
    try:
        record = await db.get(Record, record_id)
        if record:
            return LookupResult.FOUND, record
        return LookupResult.NOT_FOUND, None
    except DatabaseError:
        return LookupResult.UNAVAILABLE, None

# Caller MUST handle all three:
result, record = await lookup_record("REC-2026-001", db)
if result == LookupResult.FOUND:
    score = compute_match(record)
elif result == LookupResult.NOT_FOUND:
    score = 0.0
    flags.append("Record not found in system")
else:  # UNAVAILABLE
    score = None
    flags.append("Lookup failed - manual review required")

Rule 3: Skipped Work Gets Zero Credit

If a validation rule, test, or check could not execute - missing data, unavailable service, unsupported format - its contribution to any aggregate score must be zero, and it must be excluded from both the numerator and the denominator.

This addresses what we call the denominator problem:

# 10 rules total. 2 passed. 8 skipped.
#
# WRONG: score = (2x1.0 + 8x0.5) / 10 = 60%
#   Looks healthy, but 8 checks never ran.
#
# WRONG: score = (2x1.0 + 8x0.0) / 10 = 20%
#   Penalizes for checks that couldn't run.
#
# RIGHT: score = 2x1.0 / 2 = 100% of checks that ran
#   BUT flagged as INCOMPLETE (8 of 10 skipped)
#   Auto-acceptance BLOCKED until skipped checks resolve

@dataclass
class ScoreResult:
    score: float           # Based only on checks that ran
    checks_ran: int        # How many actually executed
    checks_skipped: int    # How many could not execute
    checks_failed: int     # How many found problems
    is_complete: bool      # True only if checks_skipped == 0
    highest_severity: str  # Worst finding across all checks

Rule 4: Every Button Must Do Something

No UI element should render without a working handler. If the handler is not implemented yet, the element must be visually disabled with a tooltip explaining why.

// WRONG - renders a button that does nothing
<button className="btn-primary">Approve</button>

// RIGHT - if handler exists, wire it
<button onClick={handleApprove} disabled={isProcessing}>
  {isProcessing ? 'Processing...' : 'Approve'}
</button>

// ALSO RIGHT - if handler isn't built yet
<button disabled title="Approval workflow shipping in v2.1">
  Approve (Coming Soon)
</button>

This sounds trivial, but AI agents scaffold complete-looking UIs where buttons exist but nothing happens when clicked. In enterprise applications, a dead button on a critical screen erodes user trust and can delay business processes while users try to figure out if the system is broken.

The Null Taxonomy: Seven Flavors of Nothing

One of the most overlooked aspects of correctness in AI-generated code is the conflation of different types of "nothing." AI agents treat all forms of emptiness as equivalent, but each has distinct semantics that your code must respect:

# These are ALL different and must be handled distinctly:
value = None       # Never set - unknown
value = ""         # Set to empty - intentionally blank
value = 0          # Set to zero - a real quantity
value = 0.0        # Set to zero float - a real measurement
value = []         # Set to empty list - checked, found nothing
value = {}         # Set to empty dict - checked, no data
# Key doesn't exist  # The concept was never considered

# WRONG - conflates all seven:
if not value:
    return "No data"

# RIGHT - each type gets its own handling:
count = data.get("record_count")
if count is None:
    return "Data unavailable"        # Unknown
if count == 0:
    return "No records this period"   # Known zero
return f"{count} records"             # Known positive

In JavaScript and TypeScript, the problem is equally pervasive:

// WRONG - hides legitimate zero values
{record.score && <Badge>{record.score}%</Badge>}
// A score of 0 is falsy - the badge never renders

// RIGHT - only hide when truly absent
{record.score != null && <Badge>{record.score}%</Badge>}
// Zero renders as "0%" - which is correct information

Idempotency: What Happens When Code Runs Twice?

Network retries are a fact of production systems. Every state mutation must be safe to execute twice with the same input. AI agents routinely produce code that creates duplicates on retry:

# WRONG - creates duplicate on retry:
async def process_task(task_id, user_id):
    db.add(TaskLog(
        task_id=task_id,
        processed_by=user_id,
        processed_at=datetime.now(timezone.utc)
    ))
    task.status = "completed"
    await db.commit()

# RIGHT - idempotent: check state first:
async def process_task(task_id, user_id):
    task = await db.get(Task, task_id)
    if task.status == "completed":
        return  # Already done - safe retry
    if task.status != "pending":
        raise InvalidStateError(
            f"Cannot process from '{task.status}' state"
        )
    db.add(TaskLog(
        task_id=task_id,
        processed_by=user_id,
        processed_at=datetime.now(timezone.utc)
    ))
    task.status = "completed"
    await db.commit()

Error Handling That Does Not Lie

AI agents love the pattern: catch the exception, log a warning, and continue as if nothing happened. This hides failures behind a facade of stability:

# WRONG - the "log and swallow" pattern:
try:
    quality_score = await compute_quality_score(record)
except Exception as e:
    logger.warning("Quality scoring failed: %s", e)
    quality_score = 0  # Looks safe but quality check was SKIPPED

# RIGHT - make the failure visible in the result:
try:
    quality_score = await compute_quality_score(record)
    quality_status = "completed"
except Exception as e:
    logger.error("Quality scoring failed: %s", e, exc_info=True)
    quality_score = None
    quality_status = "error"
    # UI shows: "Quality check failed" - not "Passed"

The difference is not cosmetic. In the first version, a failed quality check produces the same output as a record that passed with flying colors. In the second version, the failure is visible to every downstream system and every human operator.

The Countermeasures

The protocol includes four specific practices designed to catch these failure patterns before they reach production.

Countermeasure 1: The Failure Manifest

After completing any feature, write a list of at least 5 ways the feature could produce wrong results while appearing to work correctly. Each item gets a corresponding test:

# Failure manifest for: Duplicate Detection
# 
# 1. Same file uploaded twice -> should reject second upload
# 2. Different file, same reference number -> should flag
# 3. Same line items from same source -> should detect
# 4. Detection service down -> should NOT auto-accept
# 5. Empty file uploaded -> should reject, not create empty record
# 6. Hash collision (different content) -> should compare content
# 7. Upload retry after timeout -> should be idempotent

Countermeasure 2: Invariant Tests Before Implementation

Before writing any feature code, write tests that assert the properties the system must hold - especially negative properties:

def test_unvalidated_record_has_zero_score():
    """A record with no validation results scores zero."""
    score = compute_overall_score([])
    assert score == 0.0  # Not 100.0

def test_missing_reference_cannot_validate():
    """A record referencing non-existent data cannot validate."""
    record = create_record(reference="REF-DOES-NOT-EXIST")
    result = run_validation(record)
    assert result.confidence_score < 50
    assert result.status != "verified"

def test_skipped_rules_contribute_zero():
    """Skipped validation rules must not inflate scores."""
    results = [
        {"rule_id": "1.01", "status": "pass"},
        {"rule_id": "1.02", "status": "skipped"},
    ]
    score_result = compute_overall_score(results)
    assert score_result.checks_ran == 1
    assert score_result.checks_skipped == 1
    assert score_result.is_complete == False

Countermeasure 3: Chaos Fixtures

For every entity in the system, maintain an adversarial test fixture designed to break assumptions:

CHAOS_FIXTURES = [
    {"reference": "REF-GHOST-999"},          # Non-existent reference
    {"amount": -5000},                       # Negative amount
    {"date": "2099-01-01"},                  # Future date
    {"source_id": blocked_source_id},        # Blocked source
    {"reference": ""},                       # Empty string (not None)
    {"identifier": "ID-' OR 1=1; --"},       # SQL injection attempt
    {"line_items": []},                      # No line items
    {"currency": "INVALID"},                 # Non-existent currency
    {"amount": 0.0},                         # Zero-value record
    {"amount": 99999999.99},                 # Extremely large amount
]

Countermeasure 4: The "Nothing Exists" Test

For every feature, write a test where the primary entity exists but all related data is missing:

def test_record_with_no_related_data():
    """Record exists but all related data is missing."""
    record = create_bare_record()
    detail = get_record_detail(record.id)

    assert detail.validation_score == 0    # Not 100
    assert detail.confidence_score == 0    # Not "verified"
    assert detail.quality_level != "high"  # Unknown is not high
    assert detail.quality_score is None    # Not assessed
    assert detail.status != "accepted"     # Cannot auto-accept

Embedding the Protocol in Your AI Workflow

The protocol works only if the AI agent can see it. Here is how to integrate it into the tools your team already uses:

Claude Code / Claude Projects: Place the protocol as CODING_PROTOCOL.md in your project root and reference it in .claude/CLAUDE.md. Claude reads these files at the start of every session.

Cursor: Add the protocol to .cursorrules in your project root. Reference the key rules and point to the full protocol file.

GitHub Copilot: Use .github/copilot-instructions.md to embed the cardinal rules, with links to the full protocol document.

Any LLM-based tool: Include the cardinal rules in your system prompt and reference the protocol file in your project structure. The agent can only follow rules it can see.

The Complete Protocol - Free to Use

The full AI Coding Protocol is available as an open document that any engineering team can adopt, adapt, and extend. It covers 21 sections: defensive coding standards, state machine discipline, scoring systems, test philosophy, AI-specific safeguards, security defaults, concurrency patterns, the null taxonomy, timezone discipline, N+1 prevention, idempotency, error handling, API design, frontend patterns, data migration safety, observability, configuration management, documentation standards, search-for-siblings methodology, browser evidence protocol, and a complete PR checklist.

The protocol is structured for incremental adoption - start with the four cardinal rules, then add sections as your codebase matures. Every rule includes WRONG and RIGHT code examples, the specific bug it prevents, and the reasoning behind the requirement.

Get the protocol: The complete UNIVERSAL_CODING_PROTOCOL.md is open-source and ready to fork at github.com/SumvecAI/AIDLC. Drop it into your project, point your AI agent at it, and start building software that fails correctly.

Because in software that matters, the most dangerous bug is not the one that crashes your system. It is the one that runs flawlessly while producing the wrong answer.