Stop Building Fragile Scrapers — Build Actors Instead

Fig. 1 — Concerns owned by the developer (script) vs. concerns owned by the runtime (actor).

TL;DR — A “scraper” is a script that ran once. An “actor” is a unit of work with an input contract, an output schema, observability, and a billing model. Same code, completely different operational surface. We migrated our Bayut property pipeline from the first to the second this quarter and the support load dropped 70%.

I get sent a lot of scraper repos to “review” — usually after they’ve broken in production. They look surprisingly similar:

  • One Python file, 300–600 lines.
  • A main() that loops over URLs.
  • requests.get() plus BeautifulSoup plus a try/except: pass that swallows everything.
  • Output written to a CSV called output.csv in the working directory.
  • A cron job that triggers it nightly. Sometimes a Slack webhook on failure that stopped working six months ago.

This is what I call a script that ran once. The fact that it ran in production doesn’t make it production code.

The teardown is always the same.

The five failure modes you inherit when you ship a script

  1. No input contract. The script reads URLs from a hardcoded list or a file path that only exists on your laptop. New requirement → edit the file → redeploy → hope.
  2. No output schema. Whatever fields happened to be present this run get written. When the source site adds a column, the CSV silently widens. When the source site removes a column, downstream breaks at parse time, three hops away from the cause.
  3. No observability. “Did it run last night?” is answered by SSH-ing to the box and ls -la output.csv. Run history is the file’s mtime. Failure mode is “the file is older than expected.”
  4. No retries with backoff. A 503 from the target site at 02:14 kills the run. There is no second attempt. The next run is in 24 hours.
  5. No billing surface. The cost of running it is your time and your server. There is no per-unit price, so there is no signal that the unit economics are bad until you check the AWS bill.

A script is fine for “I need this data once.” It is not fine for “we need this data nightly for the next two years.” But teams keep shipping #1 to fulfill #2.

What an actor is

Strip the marketing word and an actor is just: a containerised job with a declared input schema, a declared output schema, and a runtime that handles scheduling, retries, logs, persistent storage, and billing. Apify is one implementation — there are others. The shape matters more than the vendor.

When we rebuilt our Bayut property scraper as an actor, four things changed at the level of code:

// 1. Input is validated against a schema before main() runs.
//    Bad input fails fast with a useful error, not silent miss.
const input = await Actor.getInput(); // INPUT_SCHEMA.json enforces shape

// 2. Output goes to a typed dataset. New fields require a schema
//    change — not a silent CSV widening.
await Dataset.pushData({
  listingId, price, currency, address, lat, lng, scrapedAt
});

// 3. Failures retry with backoff at the platform level.
//    Our code throws; the runtime decides what to do.
throw new ScrapeFailure('listing-blocked', { url, status: 429 });

// 4. Logs are structured, queryable, and indexed by run.
log.warning('rate-limit', { url, retryAfter: 60 });

That’s it. Same Playwright, same selectors, same scraping logic. The difference is that all the boring infrastructure — input validation, output typing, retries, logs, scheduling, billing — is no longer your problem.

Fig. 1 — Concerns owned by the developer (script) vs. concerns owned by the runtime (actor).

Result

For Bayut specifically, three months after the migration:

  • Mean time to detect a breakage went from ~36 hours (next-day stakeholder complaint) to under 15 minutes (failed runs alert with the offending URL and HTTP status).
  • Support tickets dropped 70%. Most of the volume was “the data is missing” — invisible failures from the cron-script era. With per-run datasets, failed runs surface themselves.
  • Cost per 1000 listings went down, not up. Concurrency at the runtime level is cheaper than spinning up your own queue.

The migration itself took about a week. Most of the time was not the scraping logic — that was already there. It was deciding what the input schema should be, what the output schema should be, and which fields were “nice to have” vs “the dataset is broken without this.”

The replacement pattern

If you’re sitting on a script-shaped scraper right now, the migration order is:

  1. Write the input schema. Force every run to declare what it’s scraping.
  2. Write the output schema. Force every row to validate before it gets persisted.
  3. Move retries from try/except: pass to the runtime.
  4. Replace print() with structured logs.
  5. Containerise. Whatever runs in python main.py should run in docker run.
  6. Pick a runtime — Apify, your own k8s cron, whatever. The schema work is portable.

You do steps 1–5 inside your existing repo. You haven’t committed to a vendor yet. By the time you reach step 6, the actor exists — the runtime is just a deployment target.

We packaged this migration shape into a starter we use for every new client engagement — same six steps that produced the Bayut property scraper above. Same six steps, every time.

Which of the five failure modes is currently shipping in your stack? Drop it in the comments — I’ll point at the smallest change that fixes it.

Written by **Jonas Keller, Senior Automation Architect at SIÁN Agency. Find more from Jonas on dev.to. For custom scraping or automation work, hire SIÁN Agency.

I built a protocol that reduces AI prompts by 70% — here’s the proof

The claim

Most developers know that AI prompts are
inconsistent. You write 80 words describing
a component, the AI generates something
close but not quite right, you iterate,
you waste time.

I’ve been working on a different approach:
instead of writing better prompts, what if
you had a structured protocol that eliminates
the ambiguity entirely?

That’s NEXUS — a minimalist Human-AI
communication protocol. And instead of
just claiming it works, I built a library
of 25 real examples showing the before
and after.

The comparison

Here’s a real example — a webhook handler:

Without NEXUS (87 words of natural language):

“Create a POST endpoint in Express to receive
Stripe webhooks. It should read the body as
raw buffer, verify the webhook signature using
stripe.webhooks.constructEvent() with the secret
from environment variables, return 400 if the
signature is invalid, and call
WebhookService.handleStripe() with the event.
Respond with received: true if everything works.”

Result: variable. Depends on the model,
the day, the context.

With NEXUS (8 lines):

@Express
Controller WebhookController
  Router ApiV1
    Endpoint POST /webhooks/stripe
      !! "La firma del webhook debe ser válida"
      => WebhookService.handleStripe()
      !error:400 -> /error/invalid-signature

Result: deterministic. Every time.

The numbers across 25 examples:

  • Average reduction: ~70% less text
  • Ambiguity: zero
  • The AI knows exactly what to build

Why it works

Natural language compresses intent into
sentences that humans parse easily but
AI models resolve inconsistently.

NEXUS makes intent explicit:

  • !! preconditions fire before the action
  • !error:code handles failures after
  • => is the action — nothing implied

The model doesn’t decide what to do.
The protocol tells it.

See the 25 examples

I built a full library showing the
three-panel comparison for every example:

  1. Natural language prompt (what you’d
    write today)
  2. NEXUS blueprint (8-16 lines)
  3. Generated code (the output)

Cards, navbars, forms, REST APIs,
authentication flows — all with the
before/after numbers.

👉 nexuslang.dev/examples

The library is open source:
💻 github.com/open-souse/Nexus
📦 npm install nxlang

Curious what examples you’d want to see
next — what’s the most painful component
to describe to an AI in natural language?

OpenHuman: The Open-Source AI Assistant That Wants to Become Your Second Brain

Artificial Intelligence is rapidly moving beyond simple chatbots. Today’s users want AI systems that can remember context, understand workflows, connect with tools, and actually help in durlay-to-day productivity. This is where OpenHuman GitHub Repository enters the picture.
Built by TinyHumans AI, OpenHuman is an open-source “personal AI super intelligence” designed to act more like a persistent digital companion than a temporary chatbot. Unlike traditional AI tools that forget everything after each session, OpenHuman focuses heavily on memory, personalization, privacy, and deep integration with your digital life.

What is OpenHuman?

OpenHuman is a local-first AI assistant that combines:

  • Long-term memory
  • AI agent capabilities
  • Tool integrations
  • Voice interactions
  • Desktop automation
  • Personalized context understanding

The project positions itself as a private AI runtime that learns about you continuously and becomes smarter over time. According to the official repository, OpenHuman is designed to “integrate with you in your daily life.”
What makes the project especially interesting is its ambition to move AI from “question-answering” into a real-world personal operating system for productivity.

Key Features of OpenHuman

1. Massive Memory System

One of OpenHuman’s biggest highlights is its memory architecture.
The platform claims support for up to 1 billion tokens of memory, allowing the AI to remember emails, documents, notes, workflows, meetings, and user preferences over time.
Instead of relying only on temporary chat history, OpenHuman creates a structured memory tree that continuously evolves.
This allows the assistant to:

  • Recall previous conversations
  • Understand recurring tasks
  • Maintain long-term user context
  • Learn workflows automatically

2. 118+ Integrations
OpenHuman supports over 118 third-party integrations including:

  • Gmail
  • GitHub
  • Slack
  • Notion
  • Google Calendar
  • Stripe
  • Jira
  • Linear
  • Google Drive

and many more.

The integrations work through OAuth connections and expose tools directly to the AI assistant.
This means the assistant can:

  • Read schedules
  • Understand projects
  • Summarize updates
  • Organize workflows
  • Provide proactive suggestions

3. Local-First Privacy
Privacy is becoming one of the most important discussions in AI.
OpenHuman focuses heavily on local execution and on-device memory storage. According to project documentation, data is stored locally using SQLite and can also sync into an Obsidian-compatible markdown vault.
This approach gives users more ownership over:

  • Personal data
  • AI memory
  • Documents
  • Workflows

Unlike cloud-only AI platforms, OpenHuman aims to reduce dependency on external servers.

4. Obsidian-Style Knowledge Base
Another standout feature is its Obsidian-compatible memory vault.
The system converts connected information into markdown knowledge chunks that can be browsed, edited, and organized like a personal wiki.
This creates a fascinating bridge between:

  • AI assistants
  • Personal knowledge management
  • Second-brain systems

For users already working with tools like Obsidian, this integration can be extremely valuable.

5. Voice and Desktop Presence
OpenHuman is not just a text chatbot.
The project includes:

  • Voice interactions
  • Speech-to-text
  • Text-to-speech
  • Animated desktop mascot
  • Background AI processing

The assistant is designed to feel more “alive” and persistent rather than appearing only when manually opened.

Technical Stack

OpenHuman uses a modern desktop architecture built with:

  • Rust
  • React
  • Tauri
  • TypeScript
  • QuickJS sandbox runtime

The main application combines a Rust-powered backend with a React-based UI, while integrations and “skills” run in isolated environments.

This architecture provides:

  • Better performance
  • Lower memory usage
  • Cross-platform compatibility
  • Stronger security isolation

Why OpenHuman is Gaining Attention

OpenHuman has recently gained significant traction in the open-source AI community.
Several reasons explain this momentum:

AI Fatigue with Traditional Chatbots

Many users are frustrated by AI systems that:

  • Forget context
  • Require repeated prompting
  • Lack personalization
  • Depend heavily on cloud services

OpenHuman directly targets these pain points.

Rise of AI Agents

The industry is rapidly shifting from static chatbots toward AI agents capable of:

  • Taking actions
  • Managing workflows
  • Using tools
  • Automating tasks

OpenHuman positions itself as part of this “agentic AI” movement.

Local AI Movement

There is increasing interest in:

  • Offline AI
  • Private AI
  • Self-hosted AI systems
  • Local LLMs

OpenHuman aligns strongly with this trend by enabling local execution and persistent user-owned memory.

Current Limitations

Despite the excitement, OpenHuman is still in early beta.
The developers openly mention:

  • Rough edges
  • Ongoing development
  • Potential bugs
  • Frequent updates

Users should expect instability while the project matures.
Additionally, because the platform handles sensitive data and deep integrations, security and permission management will remain critical areas to watch.

The Bigger Vision Behind OpenHuman

OpenHuman represents a broader shift happening in AI.
Instead of AI being:

  • A website
  • A chatbot
  • A prompt box

the future may look more like:

  • Persistent AI companions
  • Personal operating systems
  • Digital memory assistants
  • Autonomous workflow agents

Projects like OpenHuman are exploring what happens when AI becomes deeply integrated into daily life rather than isolated to short conversations.

Final Thoughts

OpenHuman is one of the most ambitious open-source AI assistant projects currently gaining momentum in the developer ecosystem.
Its combination of:

  • Long-term memory
  • Local-first privacy
  • Deep integrations
  • Personalized workflows
  • Open-source architecture

makes it stand out in an increasingly crowded AI landscape.
While the project is still early in development, it offers a compelling glimpse into the future of personal AI systems.
For developers, productivity enthusiasts, and AI researchers, OpenHuman is definitely a project worth watching.

Useful Links:

OpenHuman GitHub Repository

OpenHuman Official Website

OpenHuman Documentation

TinyHumans AI GitHub Organization

My Linkedin

Anthropic’s Code With Claude Announces Managed Agents, Proactive Workflows, Capability Curve

Anthropic hosted “Code with Claude 2026” in San Francisco, featuring livestream sessions focused on Claude Code, the Claude API platform, and other projects. Key topics included developer experience, autonomy features, model step-changes, and the impact of AI on product architecture. Discussions included insights from GitHub, Vercel, and AI-native startups on engineering strategies and challenges.

By Andrew Hoblitzell

The Agent’s Word Is Not Enough: External Validation in the Agentic Governance Stack

Dynamsoft Barcode SDK: React Native vs Flutter vs .NET MAUI comparison

This article was originally published on EthereaLogic.ai.

The first two articles in this series established a distinction that anchors the whole governance framework: the layers that live in documents tell the agent what to do, and the layers that run as code are what make the system trustworthy. Documents explain the rules. Hooks make rules physically impossible to violate inside the harness. But hooks intercept actions, not claims. The hook in GovForge blocks a direct push to main. It says nothing about whether the code the agent wrote is correct, whether the tests the agent ran actually covered the changed behavior, or whether a dependency in the last install carries a known CVE the agent did not flag. Those questions live outside the hook’s jurisdiction — and outside the agent’s own reporting as well.

On April 20, 2026, in the post-PR #258 sync record, the GovForge project’s primary agent reported 1,361 passing tests from its local validation run: 1,159 backend and 202 frontend, all green. The most recently completed CI run on main at that moment reported 1,152 passing backend tests. The backend discrepancy was 7 tests, and the agent had not misreported anything. The tests it ran locally were real. They passed. CI’s lower count reflects GOVFORGE_RUN_LLM_TESTS=0, which GovForge’s CI configuration sets explicitly to disable LLM-integration tests that require a local Ollama endpoint — unsuitable for a clean CI runner that has no GPU or local model dependency. The agent’s count was accurate for its local development environment. It was not accurate for the environment that governs a merge to main. CI is the layer that knows the difference.

That is what the external-validation layer is for. This is the third and final article in the EthereaLogic series on the agentic governance stack. It goes inside Layer 5 — the one that runs independently of the agent, from a clean environment with no access to the agent’s session state, and treats the agent’s self-report as a starting point, not a conclusion.

Layer 5 of the five-layer agentic governance stack — External Validation — shown as the bottom horizontal band beneath Layers 1–4. The band shows three parallel job columns labeled Quality (lint, typecheck, tests, coverage), Static Analysis (Codacy), and Dependency Scan (Snyk), each running in a clean runner environment with no session state access. SHA-pinned action references appear beneath the columns: actions/checkout@de0fac2 # v6.0.2 and similar. A green CI status badge at the right is captioned

Layer 5 runs in an environment the agent did not configure, with tools the agent does not control, producing reports the agent cannot overwrite. The three-job shape — quality, static analysis, dependency scanning — independently covers the three principal failure modes an agent can produce without triggering a hook.

The Failure Mode Hooks Cannot Close

Hooks intercept actions. They stop an agent from taking a destructive step inside the harness — committing to a protected branch, deleting a file outside the permitted root, constructing a shell pipeline that smuggles a protected operation through a nested command. The GovForge pre-tool-use.js guard covers all of that. What it cannot cover is everything that happens once the allowed action lands.

An agent can write a test suite that passes because it tests the wrong behavior. An agent can run a dependency install and report success without checking whether any installed package has a known vulnerability. An agent can complete a typecheck under a configuration that silences the errors the updated code introduced. None of these are destructive operations in the sense the hook is designed to block. All of them are failure modes that a clean external CI run — starting from a fresh checkout with an authoritative environment definition — is positioned to surface before the PR merges.

The failure mode hooks cannot close is not about what the agent does wrong. It is about what the agent cannot see. The agent’s session state is its own. Its test runner runs in its own process. Its dependency install uses its own cache. Its environment variables are its own. None of that maps cleanly onto what CI sees, because CI starts over from nothing every time. The divergence the April 20 sync record surfaced — 1,159 local backend vs. 1,152 CI backend — is not a failure. It is a correct representation of two different environments answering the same question differently. The external-validation layer’s contribution is precisely that: it answers the question from outside.

What External Validation Actually Is

External validation, in the context of this governance stack, means a CI suite that runs in a clean runner environment, on a fresh checkout, with no access to the agent’s session state, and produces reports the agent cannot overwrite or amend.

Each of those properties matters independently. Clean runner means no implicit carry-over from the agent’s local environment — no agent-generated environment variables, no state from prior sessions, and no packages except those explicitly cached in the workflow definition itself. Fresh checkout means CI sees exactly the committed code, not the agent’s working tree. No session-state access means CI does not know what the agent ran locally, what the agent reported, or what the agent believes to be true. Reports the agent cannot overwrite is what makes the external-validation layer irreversible: Codacy’s analysis, Snyk’s dependency scan, and Codecov’s coverage upload are generated by tools the agent did not write and does not control, attached to the commit or PR as artifacts that exist independently of anything the agent says.

The tool configuration that produces those properties is not complex, but it has a shape that has emerged consistently across the four production projects in the development directory: a quality job, a static-analysis job, and a dependency-scanning job, each running independently with blocking behavior assigned deliberately. The shape is the point. Any one job can miss a failure mode the other two catch; all three together cover the principal failure modes an agent can introduce without triggering a hook.

The Three-Job Shape

The GovForge ci.yml is 79 lines and contains three jobs: lint-and-test, codacy, snyk. It is the reference shape for the pattern.

The lint-and-test job runs on Python 3.11 and 3.12 in a matrix, which means every push produces two job instances — each running the full gate sequence: marker scan, Ruff lint, mypy typecheck, pytest with coverage, frontend tests, and frontend build. The matrix is load-bearing: an agent can inadvertently introduce a type annotation or syntax form that is valid in one Python version and invalid in another, and the matrix catches the divergence before the PR merges. The job sets GOVFORGE_RUN_LLM_TESTS: "0" at the env level, which is the environment variable whose value explains the April 20 test delta. That variable is the CI configuration’s way of stating that LLM-integration tests are out of scope for a clean runner: they require a local Ollama endpoint (http://localhost:11434), and a clean CI runner has no GPU or locally running model to satisfy that dependency. The agent runs them locally because local development benefits from the full test surface. CI does not run them because CI is not local development.

The codacy job runs Codacy’s analysis CLI from a SHA-pinned action. Codacy is a static-analysis platform that inspects code for quality issues, security patterns, complexity violations, and duplication — patterns that pass linting and typechecking but signal structural problems. It applies its own rule set, not the project’s. An agent that writes code that passes Ruff and mypy can still produce code that Codacy flags as a cyclomatic complexity violation or a security anti-pattern. The codacy job has no continue-on-error flag, which means a Codacy block fails the overall CI status.

The snyk job runs Snyk’s dependency scanner from a SHA-pinned action, with continue-on-error: true. The continue-on-error flag is not a concession; it is a deliberate design choice. Snyk operates against a live vulnerability database that is updated continuously. A Snyk finding on a push may reflect a CVE disclosed hours ago against a dependency that has not yet shipped a patched version. Blocking the merge on a finding with no available fix produces a CI configuration that generates blocked PRs with no actionable resolution path. continue-on-error: true means the scan executes in CI and its output is visible in the workflow logs without blocking the merge; the finding is produced independently of the agent’s self-report and is the operator’s responsibility to triage. AetheriaForge and DriftSentinel carry the same Snyk configuration for the same reason.

AetheriaForge and DriftSentinel add a fourth element: Codecov upload via codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5, configured with fail_ci_if_error: true. Codecov is a coverage-tracking service. The upload produces a coverage report attached to the PR that is independent of the agent’s local coverage output. fail_ci_if_error: true means that if the upload fails — network error, invalid token, malformed report — CI fails rather than silently omitting the coverage signal. The Codecov report is not a gate on a coverage percentage floor in these projects, but it makes coverage trends visible across PRs and does so from outside the agent’s session. The agent’s local pytest run also produces coverage output; the Codecov report is the one that is persisted, diffed against prior runs, and attached to the PR as an independent artifact.

ADWS Pro implements the same governance intent with a different job layout: a test job that runs the quality and coverage gates with local Codacy-equivalent and Codecov-equivalent checks inline, a separate security job for the local Snyk-equivalent vulnerability gate, a post-merge-signal job that writes the CI outcome (passed or regressed) to a named artifact (adws-post-merge-outcome), and an sbom job that generates a software bill of materials on every push. A separate drift-sentinel.yml workflow (50 lines) adds PR drift detection via drift_report.json. The ADWS Pro CI surface across both workflow files totals 163 lines.

The Environment Gate and the Test Delta

The April 20, 2026 sync record is the clearest available example of why the external-validation layer matters even when the agent is operating in complete good faith.

The agent’s local count — 1,361 passing tests — was a correct measurement of the GovForge test suite running in the local development environment. Every test the agent ran passed against real code. The sync record documents the measurement in detail: 1,160 backend tests collected, 1,159 passed, 1 skipped; 202 frontend tests passed across 33 test files; make validate exited 0. The agent reported what it measured. The sync record documents the claim with precision.

CI’s count — 1,152 passing backend tests, 8 skipped — reflects a different environment definition. The GOVFORGE_RUN_LLM_TESTS=0 environment variable, declared at the job level in ci.yml, disables the LLM-integration test suite. That suite has 7 tests marked @pytest.mark.llm that require a locally running Ollama endpoint at http://localhost:11434. Those tests exercise real production code paths through GovForge’s model-routing layer, but a clean CI runner has no GPU or local model process to satisfy the endpoint check in conftest.py. The CI configuration excludes them deliberately. The agent’s local development environment, where Ollama is running, does not.

The result is a documented, reproducible, and fully explained divergence between the agent’s self-report and CI’s independent count — 7 backend tests’ difference. Neither number is wrong. Both are correct descriptions of different environments applying different criteria to the same question. The external-validation layer’s role is not to catch the agent lying. It is to answer the question from the environment that governs whether code ships. The agent’s local environment is useful evidence. It is not authoritative evidence. CI is.

Two-column environment comparison. Left panel, Local Development: GOVFORGE_RUN_LLM_TESTS unset, Ollama endpoint running at localhost:11434, @pytest.mark.llm tests PASS, backend 1,159 passed + 1 skipped, frontend 202 passed, grand total 1,361 passed. Right panel, CI Runner: GOVFORGE_RUN_LLM_TESTS=0, no Ollama endpoint, @pytest.mark.llm tests SKIP, backend 1,152 passed + 8 skipped. Center delta callout: 7, labeled

The agent’s local count and CI’s count are both correct. Both accurately describe their respective environments. Only CI’s count governs whether the branch merges — and CI’s environment is defined by the workflow file, not by the agent’s session.

This distinction is the single most important property of the external-validation layer, and it is the one most likely to be papered over in an agentic deployment that has only the first four layers. A team that treats an agent’s self-reported test pass as a merge signal without independent CI confirmation is implicitly trusting that the agent’s environment matches the CI environment, that the agent’s test configuration matches the CI configuration, and that the agent’s dependency state matches what a fresh install would produce. All three assumptions are wrong on a long enough timeline, and all three are corrected by the time an external CI run finishes.

SHA-Pinning and the Infrastructure CI Runs On

The external-validation layer depends on the CI infrastructure itself being trustworthy. If the actions that CI invokes are mutable — that is, if the identifier used to reference them can resolve to different code on different days — then CI is not actually independent. It is dependent on whatever the action maintainer most recently published under a given tag.

This is not a theoretical risk. GitHub’s own documentation on hardening workflows for third-party actions names mutable version tags as a documented attack vector. A tag like v4 points to the latest commit on the v4 release line; if the action maintainer pushes a new commit to that line, every workflow referencing @v4 begins running the new code on its next invocation. The workflow author may not know. CI does not inherently warn that the referenced tag now resolves to different code. The behavior change is silent.

SHA-pinning closes this class entirely. A reference like actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 resolves to exactly one commit, permanently. If the action maintainer pushes a new commit to the v6.0.2 tag, the SHA-pinned workflow is unaffected — it still resolves to the commit that was current at the time the workflow was authored. The comment annotation (# v6.0.2) serves the human reader; the SHA serves the runtime. Both are required, in the same way that a well-written hook has a clear stderr message for the agent and an exit code 2 for the harness.

Representative SHA-pinned actions from across the four production projects include:

# Checkout — GovForge and ADWS Pro
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

# Checkout — AetheriaForge and DriftSentinel
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

# Python setup — GovForge
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0

# uv setup — GovForge
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0

# Bun setup — GovForge
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2

# Codecov — AetheriaForge and DriftSentinel
- uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5

# Codacy — GovForge, AetheriaForge, DriftSentinel
- uses: codacy/codacy-analysis-cli-action@d43360362776a6789b47b99ae8973510854e2d3d # master

# Snyk — GovForge, AetheriaForge, DriftSentinel
- uses: snyk/actions/python@9adf32b1121593767fc3c057af55b55db032dc04 # master

# PyPI publish — DriftSentinel and AetheriaForge
- uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0

The two earlier-stage projects — spec-driven-docs-system and sdlc_app — use unversioned tag references (actions/checkout@v6, actions/checkout@v4, actions/setup-node@v4) for their standard setup actions. spec-driven-docs-system SHA-pins the non-standard gitleaks action (gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9) while leaving the standard actions on floating tags. sdlc_app pins nothing. Both gaps are documented as known rather than deliberate — the same production-project standard has not yet been backported to either earlier-stage project.

SHA-pinning is the practice that most visibly distinguishes a CI configuration that has been audited from one that has been copied from a tutorial. Most tutorials use version tags because version tags are easier to read and maintain. That ease is the same property that makes them mutable. SHAs trade legibility for integrity. The # v6.0.2 comment restores most of the legibility without giving up the integrity. For an agentic project where CI is the independent verifier, allowing the verifier to silently change its behavior is the same class of problem as allowing the agent to modify its own test suite. The SHA is not legibility overhead. It is integrity.

The Rule That Makes the Layer Load-Bearing

External validation collapses into theater if the agent’s self-report can substitute for CI’s report when the two disagree.

The rule that prevents this is stated in the first article in this series and worth repeating precisely: if the agent claims tests pass, CI confirms it; if CI disagrees, the claim is unverified. There is no third case. A PR does not merge because the agent says it should. A PR merges because CI says the gates passed. The distinction is operationally significant: when an agent reports that a branch is ready to merge, the response is not to merge it but to wait for CI.

This rule has to be enforced at the workflow level, not the instruction level. An instruction that says “wait for CI before merging” is a document-layer directive with document-layer enforceability: the agent reads it and acts on it correctly, or it does not. The recommended enforcement mechanism is branch protection — a GitHub branch protection rule that requires all CI checks to pass before a PR can be merged, with no administrative override available to the agent. When configured, that setting exists outside the agent’s control and outside the operator’s day-to-day attention; if CI fails, the merge button is unavailable. The rule becomes structural rather than advisory.

This independence holds only if the workflow definition and required status checks are themselves protected from agent modification. An agent with repository write access could propose a change to .github/workflows/ci.yml in a PR, but it cannot merge that PR if branch protection requires the existing CI checks to pass first — and it cannot bypass the checks by renaming or removing them without a merge that the required checks themselves would block. That circularity is the structural guarantee.

For agentic workflows specifically, branch protection is the intended analog of the hook: both transform a written policy into a structural barrier. The hook prevents the agent from committing to main directly. Branch protection prevents anyone — agent or operator — from merging a PR without a clean CI run. Together they close the full path from agent action to main: the hook closes the direct-push path; branch protection closes the PR-merge path. Neither is sufficient without the other, and the external-validation layer is what branch protection is designed to enforce. Whether that enforcement is currently wired is a per-project configuration decision; the pattern described here is the target state.

Facts

The following are measured facts drawn from the development directory and the local workflow configurations of the projects referenced, verified on May 17, 2026. They should be read within the scope of those projects.

  • Across the six active projects in the development directory, 465 total lines of GitHub Actions workflow YAML are in place across the primary CI workflows: GovForge ci.yml (79 lines, 3 jobs), AetheriaForge ci.yml (72 lines, 3 jobs), DriftSentinel ci.yml (74 lines, 3 jobs), ADWS Pro ci.yml (113 lines, 4 jobs), spec-driven-docs-system ci.yml (97 lines, 3 jobs), and sdlc_app ci.yml (30 lines, 1 job). Additional workflow files — project-sync.yml in GovForge, drift-sentinel.yml in ADWS Pro (50 lines), and separate publish.yml files in AetheriaForge and DriftSentinel — are not included in this count.
  • The three-job production shape (quality via lint-and-test, static analysis via codacy, dependency scanning via snyk) is present in GovForge, AetheriaForge, and DriftSentinel. ADWS Pro implements the same governance intent with a different layout — test, security, post-merge-signal, and sbom jobs, with quality and coverage checks inline in test and the vulnerability gate in security — plus a separate drift-sentinel.yml workflow. spec-driven-docs-system carries a three-job shape with different tools (smoke, security, isolated-install). sdlc_app carries a single validate job.
  • The April 20, 2026 GovForge sync record (anchor commit cabee9e72ca57b860bc1a967ec8d40fe9b37cda5) documents the agent-local vs. CI test count divergence: 1,361 passing tests locally (1,159 backend passed + 1 skipped + 202 frontend) vs. 1,152 passing backend tests in the reference CI run (with 8 skipped across 1,160 collected). The 7-test backend delta — local 1,159 vs. CI 1,152 — is the LLM-integration test suite, disabled in CI via GOVFORGE_RUN_LLM_TESTS: "0" declared at the lint-and-test job level. The CI run for PR #258 (commit cabee9e, which added 4 frontend tests) was in-progress at the moment the sync record was finalized; its CI counts were not yet confirmed at that timestamp.
  • All GitHub Actions invoked in ADWS Pro, GovForge, AetheriaForge, and DriftSentinel workflow files are pinned to specific commit SHAs rather than version tags. Representative SHA-pinned references are shown in the SHA-Pinning section above; the full set of pinned actions in each workflow file exceeds what is listed there. The # <version> comment annotation appears alongside each SHA to preserve human readability.
  • spec-driven-docs-system and sdlc_app use unversioned tag references for standard GitHub actions (actions/checkout@v6, actions/checkout@v4, actions/setup-node@v4, actions/setup-python@v6). spec-driven-docs-system SHA-pins the non-standard gitleaks action (gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9) while leaving the standard actions on floating tags. Both gaps are documented as known rather than deliberate.
  • Snyk is configured with continue-on-error: true in GovForge, AetheriaForge, and DriftSentinel — the dependency scan executes and its output is visible in the workflow logs, but a Snyk finding does not block the merge. Codecov is configured with fail_ci_if_error: true in AetheriaForge and DriftSentinel — a coverage upload error is CI-blocking. The codacy job in all three projects has no continue-on-error flag, meaning a Codacy analysis failure causes the check to fail.
  • The GovForge lint-and-test job declares GOVFORGE_RUN_LLM_TESTS: "0" at the env level and runs a Python 3.11 / 3.12 matrix. No equivalent LLM-test gate appears in the AetheriaForge, DriftSentinel, or ADWS Pro CI configurations at the time of writing — AetheriaForge and DriftSentinel also run a Python 3.11 / 3.12 matrix but do not use an environment-gated test suite.
  • DriftSentinel runs 416 tests under pytest as of the measurements in the first article in this series (verified April 30, 2026).

Interpretation

The following are engineering judgments drawn from operating the external-validation layer on these projects. They should be read as claims about the author’s experience, not universal prescriptions.

The self-reporting problem is structural, not behavioral. The April 20 test delta is not a case where the agent made an error. It is a case where the agent’s environment and CI’s environment differ in a defined, documented, and deliberate way. The agent’s count is true in the agent’s environment. CI’s count is true in CI’s environment. The difference between the two is load-bearing — it reflects a decision about what should and should not gate a merge to main. Without CI, that decision has no enforcement mechanism. The agent cannot know what CI knows, because CI’s environment is not the agent’s environment and is designed not to be.

The three-job shape is the minimum, not the target. Lint-and-test, static analysis, and dependency scanning together cover the three principal failure modes an agent can produce without triggering a hook: incorrect behavior, code-quality regressions, and supply-chain vulnerabilities. Any one job alone misses the other two. A team that runs only tests will ship code that passes tests and fails static analysis. A team that runs only Codacy will have no coverage signal and no dependency exposure. The three jobs are the minimum surface for an external-validation layer that can plausibly verify an agent’s self-report across the dimensions that matter most in a production context.

The continue-on-error: true decision for Snyk is an operational judgment, not a governance gap. Snyk reports against a live vulnerability database. A CVE can be disclosed and Snyk’s database updated within hours of a merge. Blocking a merge on a finding with no available fix produces a situation where the project cannot merge until someone patches a transitive dependency that the project does not control. The right response is to surface the finding in CI output and make it the operator’s responsibility to triage. Treating continue-on-error: true as a gap misunderstands the tradeoff; treating it as equivalent to not running Snyk misunderstands the value. The scan runs, the output exists in CI logs, and that output is produced independently of the agent’s self-report regardless of whether it blocks the merge.

SHA-pinning is the practice that distinguishes a configured CI pipeline from a tutorial copy. The cost of SHA-pinning an action is seconds per action: look up the SHA for the version you want, substitute it in the workflow file, annotate the comment. The benefit is that the CI pipeline’s behavior is frozen at the version you chose, permanently, regardless of what the action maintainer does next. For an agentic project where CI is the independent verifier, allowing the verifier to silently change its behavior is the same class of problem as allowing the agent to modify its own test suite. The SHA is not legibility overhead. It is integrity.

The external-validation layer makes one assumption the rest of the stack does not. Every other layer in the governance stack works with the agent: documents guide it, hooks constrain it, agent specialization shapes it. The external-validation layer does not work with the agent at all. It assumes the agent’s self-report is not authoritative, and it provides the authoritative answer from outside. That assumption is the one most agentic coding deployments quietly omit, because the agent’s self-report is usually right and building a layer that assumes it might not be feels like friction. It is not friction. It is the layer in the stack least exposed to the influence of an adversarial subagent, a misconfigured local environment, a stale cache, an undeclared environment variable, a mutable action tag, or a newly disclosed CVE — and the one whose reports exist independently of whatever the agent says about them. It is the layer that makes the output of the whole stack verifiable.

Practical Implications for Teams Considering the Pattern

If your team has hooks and no external validation, the next step is to wire a CI workflow with at least a quality job, a static-analysis job, and a dependency-scanning job. Each job should run independently, with blocking behavior assigned deliberately: quality and static analysis are good candidates for merge-blocking required checks, while dependency scanning may be better configured as advisory — surfacing findings in CI output without blocking merges on CVEs that have no available fix yet. The recommended enforcement mechanism for the blocking jobs is branch protection configured at the repository level — required status checks that block the merge button until those checks pass — rather than an agent instruction that relies on the agent reading it correctly. An instruction to wait for CI is a document; branch protection is the structural control.

When wiring the workflow, SHA-pin every action you reference. This step is the one most teams defer because it feels like premature hardening. It is not premature. The cost is minutes per repository. The benefit is that your CI infrastructure does not silently change behavior because an action maintainer updated a tag. For a project that relies on CI to independently verify agent output, CI’s own stability is not a detail. A workflow that SHA-pins its own actions and then uses those actions to verify agent-produced code is consistent end-to-end. A workflow that uses floating tags is consistent except for the part that matters most.

Choose your error-handling flags deliberately. Snyk with continue-on-error: true and Codecov with fail_ci_if_error: true are not inconsistent. They reflect different judgments about what should block a merge and what should surface as a report. The choice is not “block or ignore” but “block or surface.” A blocking Snyk finding with no available fix produces a stalled project; a non-blocking Snyk scan still produces independent CI output about the dependency surface regardless of what the agent reported.

If your team has CI but still treats the agent’s self-report as sufficient before CI completes, the operational habit to build is: the agent’s count does not close the question, CI’s count does. This habit is mechanical in principle and harder in practice than it sounds, because the agent’s self-report arrives earlier — usually before CI has finished — and it is usually right. The times it diverges from CI are exactly the times the external-validation layer earns its place in the stack. Those times are not rare; they are the scheduled condition of every project that has environment-gated tests, matrix builds, or a dependency surface that drifts faster than local installs.

If you are starting a new project, wire the three-job shape on the first commit alongside the hook and the governance documents. The reference workflow is 79 lines. The SHA-pinning adds one annotation comment per action. The branch protection rule is a repository setting, not an agent instruction. A project that ships its first commit with a working hook, a working CI pipeline, and SHA-pinned actions has answered the three questions engineering leaders actually ask about agentic coding — governance, error rates, and security vulnerabilities — from its first day of operation. Retrofitting this layer onto a project that has been running without it requires re-auditing every previous agent-produced output that was merged on the agent’s word alone. Starting governed is the lower-cost path, and it is only available at the beginning.

The five-layer governance stack is complete when all five layers are in place. The external-validation layer is the last one, and it is the one that makes the whole stack verifiable from outside. Without it, the stack is better than documentation alone — the hooks hold, the agents are specialized, the constitution governs the directives. But the output is still self-reported. The external-validation layer changes “the agent says it passed” to “CI confirms it passed.” That distinction is what regulated businesses need before they can ship agentic output into a production environment with confidence.

Get the templates

The CI workflow configurations described in this article — the three-job GovForge reference shape with SHA-pinned actions, the branch protection rule guidance, and the Snyk/Codecov configuration patterns — are available as part of the agentic governance starter kit at etherealogic.ai/agentic-governance-stack-templates. The starter kit includes the document-foundation templates from the first article, the protected-branch hook from the second, and the CI workflow from this one.

References

This is the third and final article in the EthereaLogic series on the agentic governance stack. The full five-layer stack — navigation files, constitutional governance, agent specialization, runtime enforcement, and external validation — is available as a drop-in starter kit at etherealogic.ai/agentic-governance-stack-templates.

Why I think Component-Driven Development needs a rethink in the Signal era

Component-Driven Development assumed a render model that signal-based Angular has quietly left behind. The tooling has not caught up, and I am not sure simply patching it will be enough.

The moment that made me write this

I was building ng-prism, an Angular-native component showcase tool I maintain, and I needed to update a component’s inputs from a controls panel. The naive version looked roughly like this:

const ref = vcr.createComponent(ButtonComponent);
const instance = ref.instance;

instance.variant = 'primary';
instance.label  = 'Save';
ref.changeDetectorRef.detectChanges();

It works on a pre-signal component. On a signal-based component it quietly breaks the component. variant and label are no longer plain properties. They are InputSignal objects, callable as variant(). Angular stores the InputSignal as a plain class field; there is no defineProperty setter or proxy to intercept the write. So the assignment just overwrites the field reference with a string, and Angular never finds out. The next render then blows up the first time the template evaluates variant(), because variant is no longer a function. I verified this against @angular/core 21.2.0: createInputSignal() returns a plain function with a [SIGNAL] symbol attached, no setter trap in sight.

The fix is one line:

ref.setInput('variant', 'primary');
ref.setInput('label',   'Save');

That is the version that lives in ng-prism today, in the renderer effect that drives every showcase:

// packages/ng-prism/src/app/renderer/prism-renderer.component.ts
// Simplified; real version also handles content projection and unknown-input warnings.
effect(() => {
  const inputs = this.rendererService.inputValues();
  const ref    = this.componentRef;
  if (!ref) return;

  performance.mark('prism:rerender:start');
  for (const [key, value] of Object.entries(inputs)) {
    ref.setInput(key, value);
  }
  // setInput() already marks dirty and schedules CD. The explicit
  // detectChanges() here forces it to run synchronously so the
  // performance.mark below wraps the actual render, not just the
  // dirty-marking. Also keeps timings predictable under zoneless.
  ref.changeDetectorRef.detectChanges();
  performance.mark('prism:rerender:end');
});

This is a tiny detail, but it is the symptom of a much bigger thing. The way Component-Driven Development (CDD) thinks about a component (props in, render out, args table on the side) was modelled on a world where component inputs were plain properties. That world is gone in Angular. And once you stop assuming it, a lot of the tooling we have built over the last decade starts to look like it is solving the wrong problem.

The implicit model behind classic CDD

Look at what almost every CDD tool, Storybook included, has agreed on for years:

  1. A component is a pure-ish function of its inputs.
  2. Inputs are configured as a flat dictionary, the “args”.
  3. A “story” is a specific value of that dictionary.
  4. Changing args triggers a discrete re-render cycle.
  5. Addons (a11y, viewport, knobs, controls) hook into that cycle.

In the pre-Hooks era this was the right abstraction. React class components had setState. Angular had @Input() decorators and ngOnChanges. Vue had options API. All of them had a clear, discrete moment where a property got assigned, the framework noticed, and the component re-rendered top-down. Args tables map onto that perfectly. Whatever knob you turn becomes a property assignment, which becomes an ngOnChanges call, which becomes a render.

This is also why Storybook’s args model felt so natural for so long. Args are properties. Properties are state. State drives render. Story = scenario = property snapshot.

It was a beautiful, simple model. It is also, for Angular today, the wrong abstraction.

What Signals actually changed

I do not want to retread the “signals are great” territory. What matters for CDD is more specific.

Inputs are no longer properties. An InputSignal is a callable getter. From outside the component, the only correct way to push a value into it is ComponentRef.setInput(name, value). There is no property to assign anymore. Tools that still build their abstractions on “assign this prop” are not even wrong yet, they just produce broken components.

Components are nodes in a reactive graph, not pure functions of inputs. Half of a real-world signal-based component is computed() derived state. Those derived signals are part of the component’s behaviour. They are not inputs, but they are also not opaque internal state. An args table cannot represent them. A story shapes inputs, not derived state.

Lifecycle work is increasingly split between explicit hooks and reactive subscriptions. ngOnInit and ngOnDestroy still exist and are still idiomatic for a lot of cases (subscriptions, teardown, one-shot setup). What has changed is that ngOnChanges is largely irrelevant for signal inputs, and a growing share of the work that used to live in lifecycle hooks now lives inside effect() or computed() that is read by the template. The places where work happens have multiplied, and a chunk of it fires on a much finer-grained schedule than mount → change → destroy. CDD tools that visualise lifecycle as those three states are visualising a real but shrinking slice of what the component actually does.

Re-renders are continuous, not cycle-based. Zoneless Angular still has ApplicationRef.tick() and a ChangeDetectionScheduler. What is gone is the implicit tick driven by zone.js intercepting every async operation. In a zoneless app, change detection runs because the scheduler decided to run it, which in practice is because a signal flagged a view dirty. From the outside that looks less like a single render cycle and more like a graph of signals notifying their dependents at fine granularity. An addon that says “re-run my check on each render cycle” has no clean event to listen to, because there is no single render cycle visible at the public surface of the component.

The pre-signal mental model is not catastrophically wrong. It is just lossy. And every layer of CDD tooling pays the price of that loss somewhere.

Where the tooling lags, concretely

Three examples I have hit while building ng-prism.

1. Controls and args still treat inputs as a flat property bag.

The Storybook args object and ng-prism’s own variant config both look like this:

{ variant: 'primary', label: 'Save', disabled: false }

That is fine as a starting state. It is wrong as a model of how the component actually consumes its inputs. A signal-based input has a default expression, can be required, can be tied through computed() into other state, and can be read multiple times per render. None of that is in the args object. The args object is a snapshot of a tree it cannot see.

2. A11y, perf, and visual-diff addons assume “render happened, now check”.

axe-core, layout measurements, screenshot capture: they all rely on a moment where the DOM has settled and you can inspect it. Angular does give you some primitives here. afterNextRender() fires once after the next render. afterRender() fires after every render. ApplicationRef.isStable exposes a stable-state observable. None of those quite match what an a11y or visual-diff addon actually wants, which is closer to “the reactive graph has been quiet for N milliseconds across multiple microtasks, and I am now safe to walk the DOM”. That concept does not exist as a framework primitive. So you debounce, you wait for animation frames, you hope. In ng-prism the built-in a11y audit runs after a 500ms debounce on signal changes (A11yAuditService.scheduleAudit, default debounceMs = 500), which works in practice but feels like a workaround. I am not sure a true “graph is quiescent” signal even makes sense in a system designed to update continuously.

3. The “scenario” unit is too coarse.

A Storybook story is one set of args. A ng-prism variant is one set of input values. Neither can express “this component embedded in a parent that emits a signal stream over time”. The interesting bug in a signal-based component is rarely the static case. It is the transition. It is the moment when an upstream computed() updates twice in the same microtask and your effect runs once instead of twice. You cannot represent that with { variant: 'primary' }.

What I tried in ng-prism, honestly

ng-prism started life with the same implicit model as Storybook. Components have inputs. Inputs have values. Variants are named tuples of input values. The decorator looks similar:

@Showcase({
  title: 'Button',
  variants: [
    { name: 'Primary', inputs: { variant: 'primary', label: 'Save' } },
    { name: 'Danger',  inputs: { variant: 'danger', disabled: true } },
  ],
})

What ng-prism does that I think is on the right track:

  • Uses setInput() for every input push, never property assignment. So at least the signal contract is respected. This is the absolute floor and a surprising number of Angular-adjacent tools still get it wrong.
  • Drives the rendering loop with effect() on a signal of input values, not with a render-cycle event. The renderer reacts to whatever upstream signal happens to change.
  • Treats the scanner as a build-time concern. Signal inputs are recognised via the TypeScript Compiler API at build time, so the runtime never has to read decorator metadata. The decorator itself is literally a no-op, just a marker.
  • Supports zoneless via an opt-in flag in the ng add schematic (--zoneless), which wires up provideZonelessChangeDetection() and drops zone.js from polyfills. The default still ships with zone.js, but nothing in the renderer depends on an implicit zone tick; change detection runs because something signal-shaped told it to.

What ng-prism does not solve yet, and where I think the whole category is still stuck:

  • The variant model is still a flat input snapshot. I cannot describe a component as “embedded in a parent that pushes this signal stream over 2 seconds”.
  • computed() derived state is invisible in the UI. You see the inputs you set. You do not see the graph that hangs off them. For some components, that graph is the interesting part.
  • The a11y, perf, and box-model panels all hook into renderer output the way an old addon would. They debounce. They do not subscribe to the actual reactive graph the component is part of.
  • The “code snippet” feature generates a template string from input values. It cannot show how the component would behave in a parent where one of those inputs is itself a signal.

I am being deliberate about this in the docs. I do not want ng-prism to claim a level of insight into signal-based components that it does not yet deliver.

What I think the next generation should look like

This is the speculative part, so take it as opinion.

I think the unit of CDD will stop being “the component with these props” and start being “the component embedded in a reactive context”. A scenario will look more like a tiny fixture: this component, mounted under this parent, with these signal sources feeding it, over time. Closer to a Playwright scenario than a Storybook story.

I think tooling will need to render the reactive graph, not just the visual output. For a serious component, the dependency graph between inputs, computed(), and effect() is the documentation. We render the box and the controls. We should also be able to render the graph.

I think the addon model needs to flip. Instead of hooking into a render lifecycle that no longer exists, addons should subscribe to specific signals. Visual diff subscribes to the renderedElement signal. A11y subscribes to the same. Perf subscribes to a “graph quiescent” signal that the framework would have to expose. None of this lives in Storybook’s current architecture, and bolting it on is not obviously the right answer.

And I think “args” as a UI metaphor has run its course. A signal-based component is not configured by setting properties. It is wired up. The control surface should reflect that.

Closing

I am not arguing that Storybook is bad, or that the people maintaining it have missed something obvious. They built the right tool for the framework world of 2018, and that tool became a standard. Standards lag by definition. That is fine.

What I am arguing is that the abstraction is starting to leak in ways that matter. Signal-based Angular is a different kind of component runtime, not a faster version of the old one, and the surface area that CDD tooling was designed against has changed underneath it.

I am building ng-prism partly to test that hypothesis in code, not just in prose. I am also fully prepared to find out that I am wrong, that an args table plus setInput() plus a debounce is good enough for 95% of cases, and that the rest is academic. Possible. But I do not think it is, and I would rather find out by building than by predicting.

If you have hit the same kind of friction, or you have a counter-example where the args model still maps cleanly onto a signal-heavy component, I would genuinely like to hear it. That is more useful than another “signals are great” thread.

ng-prism is open source on GitHub. Feedback and counter-examples especially welcome.

Qwen 3.6 enable_thinking — The MoE Pitfall That Broke My Agent JSON Parsing

Qwen 3.6 enable_thinking — The MoE Pitfall That Broke My Agent JSON Parsing

I lost two hours last week to a Qwen 3.6 quirk that doesn’t show up in any quickstart guide. My agent kept returning malformed JSON. Logs showed the model output started with <think> and a 200-token reasoning monologue before the actual JSON I asked for. Parser exploded every time.

The fix is one keyword argument. The frustration is that nothing in the obvious places — model card, MLX docs, generic chat template examples — tells you about it.

If you’re running Qwen 3.6 MoE for an agent setup and your structured outputs are broken, read on.

The symptom

I had a tool-calling loop that asked Qwen to emit JSON. Something like:

prompt = "Return a JSON object with keys 'action' and 'target'."
response = generate(model, tokenizer, prompt)
data = json.loads(response)

Worked fine with Qwen 2.5. Broke immediately with Qwen 3.6. The output looked like:

<think>
The user wants a JSON object. I need to think about what action and target make sense.
Let me consider the context...
[200 more tokens of reasoning]
</think>

{"action": "search", "target": "weather"}

JSON parser saw the <think> block as garbage, threw a JSONDecodeError. Easy enough to spot once I logged the raw output. But it took me a while to realize this was a model feature, not a prompt problem.

What’s actually happening

Qwen 3.6 ships with reasoning mode default-on. The chat template injects markers — <think> and </think> — and the model is trained to fill them with its chain-of-thought before producing the user-facing answer. For interactive chat, this is sometimes useful: you can show or hide the reasoning to a user, and the reasoning content does measurably improve answer quality on hard problems.

For an agent loop that parses structured output, it’s silently destructive. Every response starts with hundreds of tokens you have to strip before you can use the actual answer. And worse, the reasoning length is unpredictable — sometimes 50 tokens, sometimes 800 — so your max_tokens budget gets eaten by thinking instead of output. On a memory-tight Mac running a 35B model already, those wasted tokens also fragment Metal cache faster — separate problem but they compound. (I wrote up the memory side in my MLX memory safety checklist if that’s the angle you hit first.)

The fix

In apply_chat_template, pass enable_thinking=False:

messages = [{"role": "user", "content": prompt}]
text = tokenizer.apply_chat_template(
    messages,
    tokenize=False,
    add_generation_prompt=True,
    enable_thinking=False  # <-- this
)
response = generate(model, tokenizer, text)

That’s it. No <think> blocks, no reasoning preamble, just the answer. JSON parses cleanly. max_tokens budget goes to the actual response.

Where the flag has to go

This took me embarrassingly long to figure out. The flag belongs at template apply time, not at generation time. You can’t pass it to model.generate() and have it work. You can’t set it as a tokenizer kwarg at load time. It only has effect inside apply_chat_template.

I tried these wrong things first:

# These do nothing — flag is ignored
generate(model, tokenizer, prompt, enable_thinking=False)
tokenizer = AutoTokenizer.from_pretrained(model_id, enable_thinking=False)
model.generate(prompt, enable_thinking=False)

If you’ve inherited a codebase where chat formatting is wrapped in a custom function, the wrapper probably calls apply_chat_template somewhere. That’s the spot. Patch it there.

When you actually want thinking on

For interactive chat where a user reads the response, leaving enable_thinking=True (the default) usually helps. The model is genuinely smarter on multi-step reasoning when it gets to think out loud. Math problems, code debugging, multi-constraint planning — all measurably better with thinking on.

So the rule isn’t “always disable.” It’s “disable for any path where the output gets machine-parsed, kept on for any path where a human reads it.”

In my own setup (a multi-agent local stack on M1 Max — full hardware notes in the 19 GB memory compression writeup), I split into two generate functions:

def generate_for_agent(messages, max_tokens=512):
    text = tokenizer.apply_chat_template(
        messages, tokenize=False, add_generation_prompt=True,
        enable_thinking=False  # parser-safe
    )
    return generate(model, tokenizer, text, max_tokens=max_tokens)

def generate_for_chat(messages, max_tokens=2000):
    text = tokenizer.apply_chat_template(
        messages, tokenize=False, add_generation_prompt=True,
        enable_thinking=True  # quality boost for chat
    )
    return generate(model, tokenizer, text, max_tokens=max_tokens)

Two functions, two contexts. Same model, same tokenizer, different chat template flag. Clean separation.

Why the docs don’t surface this

This is my speculation, not authoritative — but here’s what I think happened. Qwen 3.6 launched as Alibaba’s flagship reasoning model. The whole pitch is “thinks before it answers.” Disabling that flag in the quickstart would undercut the marketing of the feature itself. So the docs assume you want thinking on by default, and the flag is buried in API reference, not the first-page tutorial.

If your use case is agent JSON, you’ll find this gotcha on day one. If your use case is human chat, you might never need to touch the flag and won’t see why anyone would.

It’s a real-world case where the default optimizes for the most demo-worthy path, not the most common production path.

Verification

After patching, you can verify the flag took effect by inspecting the rendered template before generation:

text = tokenizer.apply_chat_template(
    messages, tokenize=False, add_generation_prompt=True,
    enable_thinking=False
)
print(text[-200:])  # tail of the prompt

You should see the assistant generation prompt with no <think> marker. If you see <think> in the tail, the flag didn’t apply — most likely because you’re calling a wrapper that doesn’t pass it through.

You can also check by inspecting the first 100 tokens of any response. Reasoning-on output starts with <think>. Reasoning-off output starts with the actual answer.

What this isn’t

This is specifically Qwen 3.6 behavior. Earlier Qwen versions (2.5 and below) don’t have the enable_thinking flag because reasoning mode wasn’t a feature yet. Other reasoning-mode models (DeepSeek-R1, the o1 family on the OpenAI API) have similar dynamics but different flags or modes — check their respective chat templates.

If your output isn’t parsable but doesn’t have <think> blocks, the cause is somewhere else. Common alternatives I’ve hit:

  • Trailing whitespace or newlines in the response — strip before parsing
  • Markdown code-fence wrapping around the JSON — strip json ` and `
  • Model adding explanatory text before/after the JSON — tighten the system prompt with explicit “no preamble, no explanation”

The <think> block fix only solves the reasoning-leak case. The other cases need other fixes.

The smaller lesson

When a new model breaks an existing pipeline silently, the bug is usually in the chat template, not the generate call. The template is the interface between your code and the model’s expectations. Most upstream API changes happen there.

For Qwen 3.6, the gotcha is enable_thinking. For the next model in two months, it’ll be something else. The diagnostic habit — log the rendered template, not just the response — saves hours over the year.

If you’ve hit a different Qwen 3.6 surprise that nobody flags, I’d genuinely like to know. Reply on the post.

Come along for the ride — see me fall or thrive, whichever comes first.

CONCEPT 1: Linux Installation Methods (Dual Boot, VM, Bare Metal)

*# *

Choosing how to install Linux is one of the most foundational decisions a system administrator, developer, or enthusiast will make. The installation method directly influences performance, isolation, hardware access, ease of experimentation, and long-term maintainability. The three primary approaches—bare metal, virtual machine (VM), and dual boot—each solve different problems and come with distinct trade-offs in architecture, workflow, and operational behavior.

This chapter explores each method in depth, from the underlying system architecture to practical implementation, real-world engineering considerations, and advanced usage patterns.

Understanding the Installation Landscape

At its core, installing Linux means placing a functional kernel, init system (most commonly systemd), root filesystem, and necessary userspace tools onto a storage medium that the bootloader can locate and execute.

The Linux kernel is a monolithic kernel that interacts directly with hardware through drivers compiled either statically or as loadable kernel modules. The bootloader (typically GRUB) is responsible for loading the kernel image (vmlinuz), initial ramdisk (initrd or initramfs), and passing kernel parameters.

Each installation method changes where and how this stack executes:

  • Bare metal runs the kernel directly on physical CPU, memory, and devices.
  • Virtual machine runs the kernel inside a hypervisor-managed environment.
  • Dual boot shares physical hardware between two operating systems.

Bare Metal Installation: Maximum Performance and Direct Hardware Control

Bare metal installation means Linux runs natively on the physical hardware without any abstraction layer between the kernel and the CPU, memory, storage controllers, or peripherals.

Why Choose Bare Metal?

Bare metal delivers the highest possible performance because there is zero hypervisor overhead. All CPU cycles, memory bandwidth, and I/O operations go directly to your Linux environment. This approach is preferred for production servers, high-performance computing, gaming desktops, audio/video workstations, and any workload where latency or throughput is critical.

Hardware Preparation and UEFI vs BIOS

Modern systems use UEFI (Unified Extensible Firmware Interface) with GPT partitioning. Legacy systems may still use BIOS with MBR. During installation, you must decide on an EFI System Partition (ESP) formatted as FAT32 (typically 512 MiB) mounted at /boot/efi.

A recommended bare metal partition layout for a production system might look like this:

/dev/nvme0n1p1     512M     FAT32     /boot/efi     (ESP)
/dev/nvme0n1p2     1G       ext4      /boot
/dev/nvme0n1p3     remaining space    LVM physical volume

LVM (Logical Volume Manager) is strongly recommended because it allows online resizing of logical volumes without downtime.

Step-by-Step Bare Metal Installation Process

  1. Create bootable media using dd, Rufus, or Ventoy.
  2. Boot from the media and enter the live environment.
  3. Partition disks using gdisk, fdisk, or the distribution installer.
  4. Format partitions (mkfs.ext4, mkfs.fat, etc.).
  5. Mount the target filesystem hierarchy under /mnt.
  6. Use debootstrap, pacstrap, or the graphical installer to populate the root filesystem.
  7. Generate fstab, chroot into the new system, install the bootloader, and configure networking.
  8. Reboot and remove installation media.

Advanced Considerations

On bare metal, you have full control over kernel parameters passed via GRUB (quiet splash, mitigations=off, iommu=pt). You can compile a custom kernel tailored to your hardware, enable specific CPU features (AVX512, huge pages), and optimize I/O schedulers (mq-deadline, bfq, or none for NVMe).

Limitations: No easy rollback. Hardware failures affect the entire system. Testing new distributions requires physical hardware or reinstallation.

Virtual Machine Installation: Isolation, Flexibility, and Rapid Experimentation

Virtual machines run Linux inside a hypervisor such as KVM/QEMU, VirtualBox, VMware, or Hyper-V. The hypervisor presents virtualized hardware (vCPU, vRAM, virtio devices) to the guest.

Architecture of Modern Virtualization

KVM (Kernel-based Virtual Machine) turns the Linux kernel into a hypervisor by loading the kvm and kvm-intel/kvm-amd modules. QEMU provides device emulation, while virtio drivers deliver near-native performance for block, network, and graphics devices.

libvirt acts as a management layer, providing a unified API for creating, starting, and monitoring virtual machines.

Creating a High-Performance Linux VM

Here is a complete example using virt-install (recommended for production-grade VMs):

virt-install 
  --name ubuntu-server-24 
  --ram 8192 
  --vcpus 8 
  --cpu host-passthrough 
  --disk path=/var/lib/libvirt/images/ubuntu24.qcow2,size=80,format=qcow2,bus=virtio 
  --network network=default,model=virtio 
  --graphics none 
  --console pty,target_type=serial 
  --location https://releases.ubuntu.com/24.04/ubuntu-24.04-live-server-amd64.iso 
  --extra-args 'console=ttyS0,115200n8'

Key options explained:

  • --cpu host-passthrough: Exposes the full host CPU capabilities to the guest for maximum performance.
  • virtio drivers: Paravirtualized devices that bypass much of the emulation overhead.
  • qcow2 format: Supports snapshots, compression, and thin provisioning.

Advanced VM Techniques

  • PCI passthrough: Assign physical GPUs, NICs, or storage controllers directly to the VM using VFIO.
  • Nested virtualization: Run VMs inside VMs (useful for testing Kubernetes clusters).
  • Live migration: Move running VMs between hosts with zero downtime.

Advantages: Snapshots, easy cloning, hardware independence, and the ability to run multiple distributions simultaneously.

Limitations: Slight performance overhead (typically 2-10% for CPU-bound tasks, higher for I/O without virtio). Resource contention on the host.

Dual Boot: Sharing Physical Hardware Between Operating Systems

Dual boot allows both Linux and another OS (usually Windows) to coexist on the same physical machine, with the bootloader presenting a choice at startup.

Bootloader Architecture in Dual Boot

GRUB is installed to the EFI System Partition and configured to detect other operating systems via os-prober. The chainloading mechanism works as follows:

  1. UEFI firmware loads GRUB.efi.
  2. GRUB reads its configuration (/boot/grub/grub.cfg).
  3. Menu is displayed with entries for Linux and Windows.
  4. Selecting Windows chainloads the Windows Boot Manager.

Safe Dual Boot Installation Strategy

Always install Windows first. Windows will claim the entire disk and create its own partitions (EFI, MSR, Windows, Recovery). Then:

  1. Shrink the Windows partition from within Windows using Disk Management or diskpart.
  2. Boot from Linux media.
  3. Install Linux into the unallocated space.
  4. Let the Linux installer install GRUB to the EFI partition (it will automatically detect Windows).
  5. Update GRUB configuration: sudo update-grub.

Common Challenges and Solutions

  • Fast Startup in Windows can leave NTFS partitions in an inconsistent state. Disable it.
  • Time synchronization: Windows uses local time, Linux uses UTC. Set Linux to use local time or Windows to use UTC.
  • Secure Boot: Sign your custom kernels or use distribution-provided signed bootloaders.
  • Shared data partition: Create an NTFS or exFAT partition accessible by both systems.

Advantages: Native performance for both OSes, access to hardware-specific applications.

Limitations: Risk of bootloader corruption, difficulty in resizing partitions later, and inability to run both OSes simultaneously.

Choosing the Right Method: Decision Framework

Consider these factors when deciding:

  • Performance needs: Bare metal for maximum speed, VM for acceptable performance with isolation.
  • Experimentation frequency: VM wins for rapid testing.
  • Hardware availability: Dual boot or bare metal when you have dedicated machines.
  • Production requirements: Bare metal or Type-1 hypervisor (KVM) for servers.
  • Development workflow: Many engineers maintain a bare metal workstation for daily work and multiple VMs for isolated testing environments.

Best practice: Start with a virtual machine to learn safely, move to dual boot for daily driver usage, and deploy to bare metal for production workloads.

Practical Engineering Workflows

Professional Linux users often combine methods. A common setup includes:

  • Bare metal host running KVM + libvirt.
  • Multiple specialized VMs (development, testing, CI/CD).
  • Physical dual-boot machine for graphics-intensive work.
  • Cloud instances for additional capacity.

This hybrid approach provides both performance and flexibility.

IMAGE DIAGRAM PROMPT

Create a highly detailed, educational diagram on a completely white background in the realistic style of handwritten notes on a professional engineering whiteboard. Use red ink for the main title “Linux Installation Methods: Bare Metal vs VM vs Dual Boot” and all major section subtitles. Use black ink for all explanatory text, labels, arrows, and technical details. The layout should resemble a comprehensive whiteboard explanation with neatly aligned sections, clear flowcharts, and connected components.

Divide the diagram into three main columns: Bare Metal (left), Virtual Machine (center), and Dual Boot (right). For each method, include:

  • Architecture stack (Firmware → Bootloader → Kernel → Userspace) with icons.
  • Key components and data flow arrows.
  • Performance indicators (CPU, I/O, overhead percentages).
  • Recommended use cases in boxes.
  • Partition layouts shown as disk diagrams.
  • Advantages and limitations in separate callout boxes.
  • Central comparison table connecting all three methods.
  • Workflow arrows showing installation steps for each.
  • Small illustrations of physical server, hypervisor layer, and dual-boot menu.

Make everything highly detailed, technically accurate, and easy to read with proper spacing, clear handwriting simulation, labeled arrows explaining relationships (e.g., “virtio drivers reduce overhead”), and professional engineering aesthetics. Ensure high visual clarity with no clutter while packing substantial educational information.

Claude wants your government ID now. Local models just won.

To use the Claude API by Anthropic, you need to provide your government-issued photo ID and a live selfie. Yes, you read that correctly.

I genuinely thought this was satire when I first saw it. It’s not.

Why This Matters

Reports have surfaced that Claude’s API access may soon require government-issued photo identification and a real-time selfie for verification. Not for enterprise contracts. Not for classified data. For API access.

Developers immediately reacted with strong emotions. From disbelief to “I knew it wouldn’t work,” the LocalLLaMA community had valid points. Hard to argue with them.

The Privacy Red Line

There’s a difference between “sign up with an email” and “hand over your passport.” One is friction. The other is a fundamentally different relationship between you and a tool.

Think about what this means in practice. Every prompt you send is now tied to your verified legal identity. Every weird experiment, every edgy test case, every half-baked prototype — all of it linked to your face and your government ID.

I don’t care how good Claude is. That’s a privacy red line for a lot of devs. 🚩

Local Models Aren’t a Compromise Anymore

Here’s the thing that makes this story bigger than one company’s policy. Local inference has gotten really good.

One year ago, if you wanted to run models locally, you had to be ready to make incredible sacrifices when it comes to quality. That difference is not even as pronounced anymore. Open weight releases like Llama 3 and Qwen are sort of… wildly competitive for most of the developer’s work. Programming assistance, summarization, data extraction – all these things, you run them locally and they just… do it.

→ No identity verification
→ No usage logging by a third party
→ No policy changes that retroactively change your relationship with the tool
→ Your data stays on your hardware, period

The hardware story has improved too. You don’t need a server rack. A decent GPU and some patience gets you surprisingly far.

Every Requirement Is Free Marketing

This is the aspect that cloud AI providers often fail to understand. Every new hoop they make developers jump through pushes another cohort toward self-hosted alternatives.

API rate limits pushed people to explore local options. Content filters that blocked legitimate use cases pushed more. And now biometric identity verification is going to push the biggest wave yet.

Each restriction is essentially a press release for the local LLM ecosystem. You couldn’t buy better marketing if you tried. 😅

I’m not saying cloud AI is dead. Managed APIs still make sense for large-scale production workloads for many teams. But individual developers? Side projects? Anything where you value autonomy over convenience?

The situation has changed significantly.

What This Really Comes Down To

Cloud AI providers are creating isolated environments. They are entitled to do so. But developers have always routed around gatekeepers when the friction gets high enough.

The trend towards identity verification is a really big tell in terms of how those companies view their relationship with customers. They want to know exactly who is using their models and exactly what they’re using them for. That’s a business decision, not a safety one.

Local models provide an alternative proposition. You trade some convenience and peak capability for ownership. Control of your data, your process, and your reputation. For a growing number of developers, that trade is looking better every month.

The open-weight community has momentum. The tools are maturing. And cloud providers keep handing them new reasons to exist. 🏔️

Here’s my question for you: Where’s your red line? What would a cloud AI provider have to require before you’d invest the time to run models locally?

Rediscovering Domain-Driven Design, one MCP server at a time

A few days ago, a devops engineer posted on r/devops:

“MCP servers just showed up in our infrastructure and I genuinely have no idea how to secure them, anyone been through this?”

Filesystem access, shell permissions, database connectors – all callable by agents without human approval. At the time I’m writing this, the thread has 76 upvotes and 39 comments from fellow engineers improvising solutions: “separate by blast radius,” “don’t mix list_files and execute_shell in one server,” “three security surfaces, not one.”

They’re all describing the same thing. They just don’t have the words for it yet.

The patterns they are rediscovering have been formalized in Domain-Driven Design (DDD), by Eric Evans.

In his book, Eric introduced concepts like Bounded Contexts and Anti-Corruption Layers, which gave us the vocabulary we’ve been using for system boundaries ever since. They helped us survive the microservices transition, and they apply directly to the architectural problems AI systems are creating right now.

We made the same mistakes a decade ago

In the 2010s, many teams adopted microservices without understanding what made them work. They took monoliths, split them apart, and called the pieces “services.” More often than not, the result was distributed monoliths – all the operational complexity of distribution with none of the architectural benefits of real, well-defined boundaries.

The correction took years. We learned (painfully) that a microservice boundary isn’t where you split the code. It’s where you split the mental model you have of the application. A payment service and a user service don’t just live in different containers – they have different vocabularies, different invariants, different reasons to change.

And the same mistake is happening again, this time with MCP servers. We wrap existing REST APIs one-to-one and call it AI integration. David Soria Parra, one of the creators of MCP, said “it’s a bit cringe, it just results in horrible things” at AI Engineer World’s Fair 2025. The Thoughtworks Technology Radar placed “MCP by default” as a Caution. And if you dive into the argument they make, they’re both saying the same thing: we’re building distributed monoliths again.

But the correction doesn’t have to take years this time. The vocabulary already exists – and has been battle-tested for more than 20 years.

Bounded Contexts: one server, one model, one language

A Bounded Context defines where a particular (data or object) model is valid. Inside the boundary, terms have precise meanings: a “transaction” in the finance context means money changing hands, a “transaction” in the booking context means a reservation. Inside each boundary lives one language and one set of rules. Across boundaries, you expect translation.

And MCP is particularly interesting from this angle: the protocol already enforces bounded contexts at the topology level.

MCP’s architecture uses a one-client-per-server model. The host spawns a separate client for each MCP server, and each client talks to exactly one server. An MCP server for your database cannot accidentally leak data to an MCP server for your file system. Unlike microservices, where any service can trivially call any other over the network, an MCP server has no protocol-level way to reach another server’s tools. You have to deliberately build that bridge. Cross-boundary coupling becomes visible and intentional rather than accidental.

But only if you design your servers as bounded contexts.

The failure mode is an MCP server that exposes everything, including filesystem access, shell execution, and database connectors in a single server. That’s three separate concerns crammed into one boundary – the equivalent of a microservice that owns users, payments, and notifications.

The commenter on Reddit who wrote “don’t mix list_files and execute_shell in one server” was actually designing context boundaries, even if he didn’t know the term.

Anti-Corruption Layers: separating the tools from domain logic

An Anti-Corruption Layer (ACL) prevents one system’s model from contaminating another. It translates between two different worldviews.

In AI systems, two fundamentally different models collide every time an agent calls a tool:

  • For the LLM, everything is strings, parameters are simple, and context is a token window. It reasons in natural language to generate structured calls.
  • The domain consists of rich types, configuration, state, complex error handling, and business invariants that must hold regardless of how they’re invoked.

The tools layer sits between these two worlds. In Chris Hughes’s words, it “protects your domain from the LLM’s interface requirements – translating between ‘strings the LLM can reason about’ and ‘rich domain objects your code works with’.”

Here’s a tool that ignores this principle:

# Everything in one function - LLM interface mixed with domain logic

@mcp.tool()
async def transfer_funds(from_account: str, to_account: str, amount: str):
    amount_decimal = Decimal(amount)
    from_acc = await db.get_account(from_account)

    if from_acc.balance < amount_decimal:
        return "Insufficient funds"

    if from_acc.is_frozen:
        return "Account frozen"

    await db.execute_transfer(from_acc, to_account, amount_decimal)
    await audit_log.record(from_account, to_account, amount_decimal)

    return f"Transferred {amount} from {from_account} to {to_account}"

And here’s the same operation with a proper separation:

# Tool layer: thin adapter (the ACL)
@mcp.tool()
async def transfer_funds(from_account: str, to_account: str, amount: str):
    result = await transfer_service.execute(
        from_account=from_account,
        to_account=to_account,
        amount=Decimal(amount)
    )
    return result.to_agent_summary()

# Service layer: domain logic, testable without the LLM
class TransferService:
    async def execute(self, from_account, to_account, amount) -> TransferResult:
        account = await self.accounts.get(from_account)
        account.validate_transfer(amount)  # raises on invariant violation
        transfer = account.initiate_transfer(to_account, amount)
        await self.transfers.save(transfer)
        await self.audit.record(transfer)
        return TransferResult(transfer)

The second version gives you:

  • Testability: the service works without an LLM. Run it from tests, CLI, scripts.
  • Replaceability: change the LLM interface (tool parameters, response format) without touching business logic. Change business rules without touching the tool layer.
  • Composability: other MCP servers, other agents, or humans can call the same service through their own interface.

The ACL protects both sides. The domain doesn’t get contaminated by the LLM’s string-based worldview. The LLM doesn’t get overwhelmed by domain complexity it can’t reason about.

The same vocabulary in a new domain

Back to that Reddit thread.

“Separate MCP servers by blast radius.” That’s bounded context design. Each server owns one domain. The blast radius is contained because the boundary is real.

“Three security surfaces, not one – tool capability, tool description, and tool call chains.” The ACL decomposed into its responsibilities. Tool capability is what the domain allows. Tool description is what the LLM thinks it can do. Tool call chains are cross-boundary interactions that need explicit orchestration.

“The dangerous part is not one tool in isolation. It is the chain.” In DDD terms: an aggregate invariant violation. A sequence of operations crossing bounded contexts without coordination. Each operation succeeds locally while the system fails globally.

Same patterns, same structural problem, discovered independently because the problem is real.

The “abstraction tax” is the ACL doing its job

One fair criticism is that MCP adds a layer. The Thoughtworks Tech Radar calls this the “abstraction tax” – every protocol layer between an agent and an API loses fidelity. Simon Willison notes that “almost everything I might achieve with an MCP can be handled by a CLI tool instead.”

This is correct. And it’s exactly the same argument people made against microservice boundaries, API gateways, and anti-corruption layers in traditional systems. The translation layer comes with costs: you lose directness.

But this loss is intentional. It’s the ACL doing its job. The LLM doesn’t need to know about your domain’s internal types, retry logic, or state management. The domain doesn’t need to accommodate the LLM’s string-based reasoning model. The “tax” buys you isolation, replaceability, and, ultimately, peace of mind.

It’s only a mistake if we’re paying this tax without getting the architectural benefit – which is exactly what REST-to-MCP 1:1 wrappers do. They add the layer without adding the boundary: all cost, no benefit.

The vocabulary already exists. Let’s keep using it.

We don’t have to reinvent these patterns – DDD has 20+ years of battle scars. We’ve learned the hard way where to draw boundaries, how to enforce them, and what happens when we don’t. AI or no AI, Eric Evans’s Domain-Driven Design is still the canonical reference for complex software systems.

MCP is already designed to establish bounded contexts; the tools layer is already an anti-corruption layer. Name your MCP servers after the domain they own, not the API they wrap, and when someone on your team says “separate by blast radius” – let them know that there are established patterns for what they’re describing.