Our Agent’s #1 Failure Mode: Thinking

Our Agent’s #1 Failure Mode: Thinking

Thirty-three tasks. Four projects. $32.93. Time to read the spreadsheet.

MissionControl has been running for a week. Quick context if you’re just joining: autonomous dev agent. Describe a coding task in Telegram, it spawns a Claude Code session, builds the feature, opens a PR on GitHub. Post 1 covered the 16-hour build. Posts 2 through 5 covered the bugs, the trust chain, the architecture, and a task that deployed a full MVP then got marked as failed. All anecdotal. Now there’s enough data to stop telling stories and start reading spreadsheets.

The Raw Numbers

Metric Value
Tasks created 33
Completed 12 (36%)
Failed 19 (58%)
Cancelled 2 (6%)
Total spend $32.93

36% completion rate. Worse than the 50% reported after 20 tasks. But the raw number lies — it’s weighed down by early infrastructure failures that no longer exist. Strip those out and the picture changes.

Where the Money Went

Not all failures are equal. Some cost pennies. One category cost almost $9.

“No commits produced” — 5 tasks, $8.88

The real failure mode. Five tasks where Opus ran for its full budget or turn limit and produced zero commits. Tasks #20, #23, #25, #27, #29 — all greenfield builds (“Build a full-stack…”) on $2 budgets.

The pattern is consistent: Opus starts by reading the entire codebase. Then it plans. Then it plans more. Explores alternative approaches. Considers edge cases it will never hit. By the time it’s ready to write code, the budget is gone.

$8.88 burned on thinking. Not a single line committed.

API and infra failures — 10 tasks, $0.69

Ten tasks failed on infrastructure issues — all fixed since. Anthropic API 500s during early testing (4 tasks, $0.69). Missing sudo, stale OAuth tokens, missing worker user (6 tasks, $0). Resolved in the first week. Noise in the data now.

Timeout — 1 task

Default timeout was too short for a full-stack build on a 2-core box. Bumped it. Hasn’t recurred.

CLI quirk — 1 task

--print combined with --output-format=stream-json silently requires --verbose. Without it, the CLI exits 1 with no useful error. Fixed in worker.ts.

The Funnel

Signal separated from noise:

33 total tasks
 - 10 infra/API failures (fixed, no longer relevant)
 -  2 cancelled
 -  1 timeout (fixed)
 -  1 CLI quirk (fixed)
 = 19 real attempts
 - 12 completed
 -  5 "no commits" (the actual problem)
 -  2 other failures

Strip the noise: roughly 63% on real attempts. Not bad for an autonomous agent with no human in the loop. But 5 tasks and $8.88 wasted on overthinking — that’s the leak.

Model Economics

Model Tasks Cost Avg/Task Raw Success Adjusted
Opus 30 $30.65 $1.02 30% (9/30) 50% (9/18)
Sonnet 3 $2.28 $0.76 100% (3/3) 100% (3/3)

Three data points isn’t a sample size. But the pattern is worth noting.

Opus’s failure mode is overthinking. Reads everything, considers everything, plans extensively. On a constrained budget, that means it runs out of money before it writes code. On greenfield builds — where the codebase is small and the task is “just build it” — this is exactly wrong.

Sonnet’s strength is mechanical execution. Clear task, does the task. No exploration spirals. No alternative-architecture tangents. Three tasks, three completions, $0.76 average.

This isn’t “Sonnet is better.” It’s match the model to the task shape. Opus for complex modifications to large codebases where understanding context matters. Sonnet for greenfield builds and mechanical fixes where the path is clear.

Three Changes We Made

The data pointed to three specific interventions. Shipped all three before starting the next batch.

1. Doubled All Budgets

Parameter Old New
Default task budget $5 $10
Max task budget $10 $20
Daily budget cap $50 $100

The hypothesis: “no commits produced” isn’t an intelligence failure — it’s a budget failure. Opus needs room to think and build. At $2, it can do one or the other. At $4-10, it can do both.

This is a bet. If doubling budgets converts those five failures into completions, the ROI is obvious — spending $4 to get working code beats spending $2 to get nothing. If it doesn’t, we have a deeper problem that money won’t fix.

2. Two-Phase Reviews

Single-phase reviews were inconsistent. Task #33 came back with “Done” and no detail. Task #31 found a real bug. Same prompt, different quality. Split analysis from execution.

Phase 1 — Opus analyzes. Read-only access. Reviews the PR diff against a structured checklist: logic errors, security, styling, imports, TypeScript compliance. Outputs a machine-readable verdict:

<!-- REVIEW_VERDICT {"approved": false, "issues": [
  "src/components/VotingPanel.tsx:42 — duplicate accent color logic",
  "src/components/Icon.tsx — missing style?: CSSProperties prop"
]} -->

Budget: $1.50. Model: Opus. Tools: read-only (Bash, Read, Glob, Grep).

Phase 2 — Sonnet fixes. If Phase 1 finds issues, a child task is auto-created. Sonnet gets the issue list, fixes each one, runs tsc --noEmit and npm run build, commits, and pushes.

Budget: $1.00. Model: Sonnet. Tools: full access.

Already caught real bugs in production PRs. The duplicate accent color in VotingPanel would have shipped. The missing style prop on icon components would have caused runtime issues in any consumer passing inline styles. Total review cost: $2.50 for analysis plus fixes — cheaper than a single Opus task that might or might not find anything.

3. Commit-Early Culture

The lead dev prompt now emphasizes incremental commits over perfect final PRs. Old pattern: plan everything, build everything, commit once at the end. Budget runs out before that final commit — zero output.

New pattern: commit after each meaningful unit of work. A partial feature with three commits is infinitely more valuable than a complete feature with zero commits.

Can’t force the model to commit early — it’s guidance, not enforcement. But combined with higher budgets, the goal is to shift the failure mode from “zero output” to “partial output.” Partial output can be retried. Zero output is wasted money.

What We’re Watching

Batch 2 starts now. Three questions:

Does doubling budgets convert failures? If the five “no commits” tasks would have succeeded at $4-10, the completion rate will show it. If they still fail at higher budgets, the problem is in the prompt or the task shape, not the money.

Does two-phase review scale? Three review tasks isn’t a pattern. Need 15-20 to know if the structured verdict format is reliable and if Sonnet consistently fixes what Opus finds.

Can we auto-calibrate? A greenfield build and a one-line config change shouldn’t share a budget. Considering scope-size flags — small, medium, large — that auto-set budget and timeout based on expected complexity. Not built yet. Waiting for more data to set the thresholds.

The Takeaway

Thirty-three tasks taught us more than building the system did. The system works. The question was always “how well?” Now we know: ~63% on real attempts, with a clear #1 failure mode we can measure and attack.

Not crashes. Not bugs. Not infrastructure. The agent thinks too much and ships nothing. Solvable problem. Higher budgets give it room. Two-phase reviews separate thinking from doing. Commit-early guidance reduces the blast radius of a timeout.

$32.93 for 33 tasks and a clear roadmap for improvement. Not bad.

Next up: batch 2 results — did the changes work?

4 pgvector Mistakes That Silently Break Your RAG Pipeline in Production

pgvector is the fastest way to add vector search to an existing PostgreSQL database. One extension, a few SQL commands, and you have similarity search running alongside your relational data. No new infrastructure. No new SDK. No vendor lock-in.

That simplicity is also its trap. Most teams add pgvector in a day and spend the next six months debugging performance issues that have nothing to do with the extension itself. The problems are almost always configuration mistakes that tutorials skip over.

Here are four I have seen break RAG pipelines in production, and how to fix each one before your team starts debating a migration to Pinecone.

No HNSW Index Means Full Table Scans

By default, pgvector performs exact nearest neighbor search. That means it scans every single row in the table on every query. For a prototype with 10,000 vectors, this is invisible. At 500,000 vectors, queries start crossing 800 milliseconds. At a million, you are looking at multi-second response times that make your RAG pipeline feel broken.

The fix is a single SQL statement: create an HNSW index on your vector column. HNSW (Hierarchical Navigable Small World) is an approximate nearest neighbor algorithm that trades a tiny amount of accuracy for massive speed improvements. After adding the index, the same 500K-vector query drops to under 50 milliseconds.

The reason this catches teams off guard is that pgvector works perfectly without the index. There is no warning, no error, no degradation signal. It just gets slower as data grows, and most teams blame the embedding model or the LLM before they check the database.

Dimensionality Is Not Free

OpenAI’s ada-002 embedding model outputs vectors with 1,536 dimensions. Each vector row in PostgreSQL consumes roughly 6 kilobytes of storage. Scale that to one million documents and you are looking at 6 gigabytes just for the embeddings column, before accounting for the HNSW index overhead, which can double or triple the total.

This matters because your AWS or cloud bill is not driven by the LLM API calls most teams obsess over. It is driven by the RDS instance size and storage needed to hold and index those vectors. A db.r6g.xlarge running pgvector with a million high-dimensional vectors costs real money every month.

The alternative is to use a smaller embedding model. Cohere’s embed-v3 outputs 384 dimensions and performs competitively on most retrieval benchmarks. That cuts storage by 75 percent and proportionally reduces index build time, memory usage, and query latency. Unless your use case specifically requires the nuance of 1,536 dimensions, smaller is almost always the right production choice.

Wrong Distance Function, Wrong Results

Most tutorials use cosine similarity as the default distance function, and most teams never question it. But pgvector supports three distance functions: cosine similarity, inner product, and L2 (Euclidean) distance. Each one measures “similarity” differently, and the choice directly affects which documents appear in your top-K results.

Cosine similarity measures the angle between vectors, ignoring magnitude. Inner product considers both direction and magnitude, which makes it the better choice when your embeddings are already normalized (as most modern embedding models produce). L2 distance measures the straight-line distance between vector endpoints, which works best when magnitude carries meaningful information.

The practical impact is real. I have seen cases where switching from cosine to inner product on the same dataset changed three of the top five results. If your RAG pipeline returns mediocre answers and you have already tuned your chunking strategy and prompt, check the distance function before anything else. It is a one-line configuration change that can transform result quality.

Know the Scaling Ceiling

pgvector is not a dedicated vector database. It is an extension that adds vector operations to PostgreSQL, and PostgreSQL was not designed to be a vector search engine at scale. In practice, pgvector handles up to about five million vectors comfortably on a db.r6g.xlarge instance with proper HNSW indexing. Past ten million vectors, expect query performance to degrade under concurrent load, and index build times to become a deployment bottleneck.

For most teams, this ceiling is not a problem. The majority of production RAG systems index fewer than five million documents. If you are in that range and already running PostgreSQL, adding pgvector is the right call. You avoid the operational complexity of a separate vector database, keep your data in one place, and eliminate an entire category of infrastructure to manage.

If you are genuinely approaching the ten million mark, look at pgvector-scale (which adds partitioning and distributed indexing) or evaluate a dedicated solution like Pinecone or Weaviate. But make that decision based on actual data volume, not on anxiety about future scale.

The Config Is the Bottleneck

The pattern I see repeated is predictable. Week one, a team adds pgvector and it works great. By month two, queries slow down and nobody thinks to check the index. By month four, someone proposes migrating to a managed vector database. By month six, a senior engineer adds one HNSW index and the problem disappears.

pgvector is a genuinely excellent tool for most production RAG systems. The mistakes that break it are not bugs or limitations. They are configuration gaps that tutorials gloss over and documentation buries. Fix the index, right-size the dimensions, pick the correct distance function, and know your scaling ceiling. That is the entire playbook.

What vector store is your team running in production right now?

Setting up Claude Code for success

When first starting a new project using Claude Code, it is easy to jump ahead, diving straight into coding. However, if you spend a bit of time setting up Claude Code, the outcome will be a smoother and more enjoyable development experience.

Generating a CLAUDE.md file

When first starting your project, spend time talking through the requirements of the project with Claude. Take into account your tech stack, What language are you writing it in? Are you using a framework?

You should also not only discuss the tech stack, but also how you want this built. How do you want the folder structure to be laid out? How do you want to interact with the database?

Here is a general list of things you might include:

  • Programming Language (and version)
  • Framework and routing approach
  • Dependency injection patterns
  • Database access layer (raw queries, query builder, ORM)
  • Coding standard
  • Folder and namespace conventions
  • Error handling approach
  • Unit Testing
  • Third party packages you might include.

The goal of this conversation is to produce a CLAUDE.md file — a markdown document that lives in your project root, that is automatically loaded into Claude’s context at the start of every session. It’s the source of truth that means you never have to re-explain your stack again.

What a CLAUDE.md looks like

Here’s an example CLAUDE.md file

# Project Blueprint

## Stack
- Language: PHP 8.3
- Framework: FlightPHP (micro-framework)
- Database: Postgres 18
- Coding standard: PSR-12

## Database Access
Use Flight PHP build in PDO wrapper with prepared statements exclusively.
All queries live inside the business logic — never in controllers.

## Folder Structure
app/
  Controllers/ # Thin controllers, Only reference Logic classes and UI
  Logic/       # Business logic
  config/      # Config files — DO NOT READ OR EDIT

## Code Style
Run PSR-12 checks via: vendor/bin/phpcs --standard=PSR12 app/
Auto-fix via:          vendor/bin/phpcbf --standard=PSR12 app/

## What NOT to do
- Never use static methods on service classes
- Never put SQL in controllers
- Never read from app/config/config.php
- Never commit directly — all git operations are manual

Once this file exists, every new Claude Code session starts with this context. Claude knows your conventions without being told.

The CLAUDE.md is a time saver when coding across multiple sessions. It also allows more frequent context clearing, which can help keep Claude Code focused on the current ask, lowering costs, and save time not having to explain design choices every session.

Items can also be added to the file as you come across issues and technical asks that may not have been in the CLAUDE.md file initially, building up a good repository of information that Claude can use to help build the application to your specifications.

We all have different ways of coding, and teaching Claude how you like to code through the CLAUDE.md file, will result in code similar to how you code yourself, which helps when reviewing the code, as it will seem familiar.

Guardrails

Claude Code is powerful, which is why it needs guardrails. Left unconstrained, an AI can read your environment files, touch your git history, run database commands, or make network requests you didn’t intend. None of that maliciously, just helpfully, and that’s the problem.

The permissions system in Claude Code lets you define exactly what it can and cannot do, locked into your project’s .claude/settings.json. The deny list is your non-negotiable safety layer.

There are three settings files:

  • managed-settings.json
  • settings.local.json
  • settings.json

These are hierarchical.

Managed-settings.json is the top tier, It lives outside the repo.

OS Path
macOS /Library/Application Support/ClaudeCode/managed-settings.json
Linux /etc/claude-code/managed-settings.json
Windows C:ProgramDataClaudeCodemanaged-settings.json

Instructions in the managed-settings.json cannot be overridden by instructions in the repo level json files.

  • settings.json should be committed to your repository.
  • settings.local.json should not be committed to the repository.
  • settings.local.json overrides settings.json.

The difference between these is that settings.json is shared across developers in a multi developer setup, while settings.local.json is intended for individual developer instructions.

In a business setting, putting the most critical instructions in the managed-settings.json, and limiting the use of settings.json & settings.local.json, sets the system for success. While as an individual developer, just using the settings.local.json file might be sufficient.

What belongs on the deny list

Environment files & secrets
Your .env, .env.*, certificates (.pem, .key, .p12), and SSH/AWS credential folders should be completely off-limits. Claude has no reason to read them. With config files, creating a sample config with no secret keys set is useful for giving Claude access to the config structure without giving access to the keys.

Destructive git operations
Block git commit, git push, git merge, git reset, git clean, and anything else that writes to your history. You own the git history, not Claude. Every commit should be decided on and controlled by a person. This allows for code reviewing prior to committing the code.

Direct database access
No mysql, psql, pg_dump, mysqldump, or any other direct database CLI commands. Claude should generate migration files and queries in code, not run commands directly against your data.

Network and remote access
Block curl, wget, ssh, scp, rsync, and similar tools. Claude should fetch docs through approved WebFetch domains, not make arbitrary outbound calls.

System-level commands
rm, sudo, chmod, chown, kill, crontab — anything that can damage your system or escalate privileges.

The full settings file

The allow list is just as important as the deny list. It gives explicit permission for the tools you do want Claude to be able to run. Fetching framework docs, requiring Composer packages, running your linter etc.

Special Note

Within a settings file, DENY instructions override ALLOW instructions, however when there is a hierarchy of settings files, the higher priority files instructions override the instructions from lower priority files. If something is denied in managed-settings.json, adding an ALLOW in settings.json will not override the denial in managed-setting.json

{
  "permissions": {
    "allow": [
      "Bash(composer require:*)",
      "Bash(vendor/bin/phpcbf --standard=PSR12 app/)",
      "Bash(vendor/bin/phpcs --standard=PSR12 app/)"
    ],
    "deny": [
      "Read(app/config/config.php)",
      "Edit(app/config/config.php)",
      "Read(.env)",
      "Edit(.env)",
      "Read(**/*.pem)",
      "Read(**/*.key)",
      "Read(**/*.p12)",
      "Read(**/*.pfx)",
      "Read(~/.ssh/**)",
      "Read(~/.aws/**)",
      "Read(./.git/**)",
      "Bash(git commit:*)",
      "Bash(git push:*)",
      "Bash(git merge:*)",
      "Bash(git rebase:*)",
      "Bash(git reset:*)",
      "Bash(git clean:*)",
      "Bash(git branch -d:*)",
      "Bash(git branch -D:*)",
      "Bash(git tag -d:*)",
      "Bash(git stash drop:*)",
      "Bash(git stash clear:*)",
      "Bash(git remote add:*)",
      "Bash(git remote remove:*)",
      "Bash(git remote set-url:*)",
      "Bash(git config --global:*)",
      "Bash(rm:*)",
      "Bash(rmdir:*)",
      "Bash(shred:*)",
      "Bash(dd:*)",
      "Bash(mkfs:*)",
      "Bash(fdisk:*)",
      "Bash(curl:*)",
      "Bash(wget:*)",
      "Bash(nc:*)",
      "Bash(netcat:*)",
      "Bash(nmap:*)",
      "Bash(telnet:*)",
      "Bash(ftp:*)",
      "Bash(sftp:*)",
      "Bash(scp:*)",
      "Bash(rsync:*)",
      "Bash(ssh:*)",
      "Bash(mysql:*)",
      "Bash(mysqldump:*)",
      "Bash(mysqlimport:*)",
      "Bash(mariadb:*)",
      "Bash(mariadb-dump:*)",
      "Bash(psql:*)",
      "Bash(pg_dump:*)",
      "Bash(pg_restore:*)",
      "Bash(pg_dumpall:*)",
      "Bash(redis-cli:*)",
      "Bash(sudo:*)",
      "Bash(su:*)",
      "Bash(chmod:*)",
      "Bash(chown:*)",
      "Bash(passwd:*)",
      "Bash(useradd:*)",
      "Bash(usermod:*)",
      "Bash(crontab:*)",
      "Bash(kill:*)",
      "Bash(pkill:*)"
    ]
  }
}

The pattern to remember: tight allow, broad deny. Explicitly permit the specific tools Claude needs; broadly block everything that could cause harm if run without your supervision.

Custom Commands

Custom commands are saved prompts you can used during any session to run a repeatable, structured action across your codebase.

The most valuable commands aren’t about generating code, they’re about reviewing what’s been built. Running a security audit through a project catches problems before they’re buried under layers of new code.

Custom commands live in .claude/commands/ as markdown files. Each file is a detailed prompt that Claude executes when you invoke the slash command.

Claude Code itself can help you draft these command files efficiently.

Commands worth building

/security-audit
Reviews all files in the application for SQL injection risks, unvalidated input, missing authentication checks, insecure direct object references, and exposed error messages. Outputs a prioritised list of findings with file and line references.

/architecture-review
Checks that the business rules are being followed.

/update-claude
“Review what we have just discussed and update CLAUDE.md with any new architectural patterns or ‘What not to do’ rules we have discovered”.

Example: the security audit command

Perform a security audit on the PHP codebase in app/.

Check for the following issues and report each with file
path, line number, and severity (high/medium/low):

1. SQL injection risks — any string concatenation in
   queries, any unparameterised input
2. Missing input validation — user-supplied data used
   without sanitisation or type checking
3. Authentication gaps — routes or methods that should
   require auth but don't check for a session
4. IDOR risks — fetching records by ID without verifying
   the current user owns that record
5. Verbose error exposure — raw exceptions or stack
   traces that could leak system details

Output format:
[SEVERITY] File: path/to/file.php (line N)
Issue: description
Fix: recommended action

Do not fix anything — report only. Fixes happen
in a separate pass once the full list is reviewed.

Run audits often. The best time to run a security audit is not at the end of the project , it’s during the project development. Issues caught early are easy to fix. Issues caught after three months of layered code become complex.

Claude Ignore

Similar to .gitignore, there is a .claudeignore file. Entries in the .claudeignore tells Claude that the files and folders listed are not relevant.

Folders like node_modules, vendor, logs, caches etc would be good one to add to .claudeignore.

.claudeignore is not a replacement for settings.json. Claude can still read items listed in .claudeignore, You can still ask Claude to read them to gain context.

.claudeignore saves you tokens by not having Claude read the files upfront.

Context Compacting

As a Claude Code session grows longer, it will eventually compact, compressing older parts of the conversation to free up context window space. Claude does its best to preserve what matters, but compacting loses context. Architectural decisions made three hours ago, the reasoning behind a particular pattern choice, all of that is at risk of being quietly forgotten.

This is where the CLAUDE.md file helps.

Because CLAUDE.md is loaded at the start of every session, and re-read whenever Claude needs to orient itself , your core context is never actually lost to compacting. It doesn’t live in the conversation history. It lives in the file.

Compacting can chew through hours of back-and-forth . Your architecture conventions are still right there, intact, waiting to be loaded again.

In long conversations, you may want to update the CLAUDE.md file with new information that came about during the conversation.

This can change how you work. Instead of using a single long session and worrying about context drift, you can:

  • Use /clear freely and often to start fresh without losing your architectural context
  • Treat each task or feature as its own clean session, describe the scope, build it, clear, repeat

Working in small, focused sessions with frequent clears is actually a healthier pattern than one long marathon session anyway. It forces you to scope tasks tightly, keeps the context window lean, and means Claude is always working with fresh, uncluttered context.

Time to code

With CLAUDE.md setup, and your system secured, you’ve created a safe space for Claude Code to be genuinely excellent.

This lets you focus on working through the architecture and delivering a quality product.

Setting up Claude Code isn’t about restricting the AI, it’s about defining the playing field so you can stop worrying about the boundaries and start focusing on the architecture.

I security-audited my own AI gateway and added WASM plugin support. Here’s what I found.

I've been building AegisFlow, an open-source AI gateway in Go. It sits between your apps and LLM
  providers (OpenAI, Anthropic, Ollama, etc.) and handles routing, security, rate limiting, and
  observability.

  Yesterday I sat down and did a proper security audit of the whole thing. Found more issues than I'd
  like to admit.

  The security stuff

  Timing attacks on API key validation. The tenant key lookup was using plain string comparison. An
  attacker could measure response times to progressively guess keys character by character. Switched
  to SHA-256 hashing both sides and comparing with subtle.ConstantTimeCompare. Also iterates all
  tenants on every check so there's no early-exit timing leak.

  inputHash := sha256.Sum256([]byte(apiKey))
  var match *TenantConfig
  for i := range c.Tenants {
      for _, key := range c.Tenants[i].APIKeys {
          keyHash := sha256.Sum256([]byte(key))
          if subtle.ConstantTimeCompare(inputHash[:], keyHash[:]) == 1 {
              match = &c.Tenants[i]
          }
      }
  }

  Admin panel was open by default. If you didn't set admin.token in your config, every admin endpoint
  was unauthenticated. Usage stats, provider health, tenant config, policy rules — all exposed. An
  attacker could read your exact jailbreak keywords and craft prompts around them. Now it blocks all
  data endpoints when no token is configured and logs a warning at startup.

  Rate limiter was failing open. When the limiter errored (Redis down, memory issue), it silently let
  requests through. Flipped it to return 503 instead.

  Other fixes: 10MB request body limit (was unlimited — one curl command could OOM the process), SSE
  injection in streaming responses (policy violation messages were string-concatenated into the event
  stream instead of JSON-serialized), and response cache was keyed by model+messages but not tenant
  ID, so tenant A could get tenant B's cached response.

  Jailbreak detection got real

  The keyword filter had 3 patterns. That's not a filter, that's a suggestion. Expanded to 25 covering
   the common techniques.

  But the bigger fix: added NFKC Unicode normalization. Before this, someone could write "іgnore
  previous instructions" with a Cyrillic і and walk right past the filter. Or add extra spaces:
  "ignore  previous  instructions". The normalizeText function now handles both:

  func normalizeText(s string) string {
      s = norm.NFKC.String(s)
      s = strings.Map(unicode.ToLower, s)
      s = multiSpaceRe.ReplaceAllString(s, " ")
      return strings.TrimSpace(s)
  }

  WASM plugin support

  This is the part I'm most excited about. You can now write custom policy filters in any language
  that compiles to WebAssembly and load them at runtime through config.

  policies:
    input:
      - name: "custom-toxicity"
        type: "wasm"
        action: "block"
        path: "plugins/toxicity.wasm"
        timeout: 100ms
        on_error: "block"

  The plugin ABI is four exported functions:

  ┌───────────────────────────────────────────────────┬───────────────────────────────────────────┐
  │                      Export                       │               What it does                │
  ├───────────────────────────────────────────────────┼───────────────────────────────────────────┤
  │ alloc(size) → ptr                                 │ Plugin allocates memory for host to write │
  │                                                   │  inputs                                   │
  ├───────────────────────────────────────────────────┼───────────────────────────────────────────┤
  │ check(content_ptr, content_len, meta_ptr,         │ Main check. Returns 0 (allow) or 1        │
  │ meta_len) → i32                                   │ (violation)                               │
  ├───────────────────────────────────────────────────┼───────────────────────────────────────────┤
  │ get_result_ptr() → ptr                            │ Pointer to result JSON after violation    │
  ├───────────────────────────────────────────────────┼───────────────────────────────────────────┤
  │ get_result_len() → i32                            │ Length of result JSON                     │
  └───────────────────────────────────────────────────┴───────────────────────────────────────────┘

  The host passes request metadata as JSON (tenant ID, model, provider, phase) so plugins can make
  context-aware decisions. Config-level action always overrides whatever the plugin returns — a plugin
   can't escalate its own permissions.

  Runtime is wazero, pure Go, zero CGO. Each plugin gets its own timeout and on_error behavior (block
  or allow). If a plugin crashes or returns garbage JSON, the gateway handles it gracefully based on
  your config.

  Wrote a Go example plugin that blocks messages containing "forbidden":

  //go:wasmexport check
  func check(contentPtr *byte, contentLen uint32, metaPtr *byte, metaLen uint32) int32 {
      content := unsafe.String(contentPtr, contentLen)
      if strings.Contains(strings.ToLower(content), "forbidden") {
          result, _ := json.Marshal(map[string]string{
              "action":  "block",
              "message": "content contains forbidden word",
          })
          resultBuf = result
          return 1
      }
      return 0
  }

  Build it with GOOS=wasip1 GOARCH=wasm go build -o plugin.wasm . and point your config at it. No
  gateway recompilation.

  Live request feed

  Added a fifth page to the admin dashboard. Shows every request flowing through the gateway in real
  time — latency, status codes, cache hits, policy violations. Auto-refreshes every 2 seconds. Nothing
   fancy, but useful when you're debugging or demoing.

  What's next

  Phase 3: Kubernetes operator with CRDs, multi-region routing, and A/B testing for models. The goal
  is making this work across clusters, not just on a single node.

  Repo: github.com/saivedant169/AegisFlow

  57 tests, all passing with race detector. MIT licensed. If you're proxying LLM traffic and want
  something self-hosted, take a look.

The Libravatar Problem Nobody Warned Me About (And How I Finally Fixed It)

I didn’t expect something as simple as setting a profile picture to turn into a full debugging session.

But that’s exactly what happened.

It started like any normal onboarding task. I had just set up my Fedora Account System account, everything was working fine, and I thought, “Let me quickly update my profile picture and move on.”

Simple, right?

I clicked “Change Avatar.”
I was redirected to Libravatar.
I logged in.
I uploaded my picture.

Done.

Or at least, that’s what I thought.

I went back to check my profile… and there it was, the same default avatar staring back at me like nothing had changed.

At first, I assumed it was just a delay. Maybe caching. Maybe I needed to refresh.

I refreshed.

Nothing.

I logged out and back in.

Still nothing.

That was the moment I realized this wasn’t just a small glitch. There was something deeper going on.

The Loop That Made It Worse

While trying to figure it out, I ran into another issue, the login loop.

At one point, instead of smoothly logging in, I kept getting prompted repeatedly for credentials. It felt like I was stuck between two systems that weren’t fully talking to each other.

That’s when I made a small but important decision, I cancelled the prompt.

And surprisingly, that worked.

It redirected me to the proper Fedora login page, and I was finally able to proceed cleanly.

It was my first clue that the problem wasn’t just about uploading an image… it was about how systems were connected.

Understanding the Real Problem

At this point, I stopped guessing and started thinking.

Libravatar wasn’t just storing images. It was linking identities.

And Fedora wasn’t just using usernames. It was relying on a specific identity mapping system — OpenID and email associations.

That’s when it clicked.

Uploading the image wasn’t enough.
The system needed a verified identity to attach it to.

The Fix That Finally Worked

Instead of trying to re-upload the image again, I took a different route.

I added a new email address inside Libravatar.
Then I verified it.

Came back. Refreshed.

And just like that — it worked.

The new profile picture finally showed up.

What I Learned

This wasn’t just about fixing an avatar issue.

It taught me something deeper about working in real systems:

Things don’t always fail loudly, sometimes they fail silently
Identity systems (OpenID, email mapping) matter more than they seem
Debugging isn’t about trying random fixes, it’s about understanding how things connect

Most importantly, I learned that the difference between being stuck and moving forward is often just one shift, from guessing to thinking.

Why I Decided to Document It

After solving it, I realized something:

If I ran into this, others definitely would too.

So instead of keeping the solution to myself, I turned it into a guide for the community, something simple, clear, and easy to follow.

Because in open source, solving a problem is good…

But making it easier for the next person?

That’s better.

If you’re interested in the documentation I prepared as a guide for resolving these issues, you can find it here

Microsoft, “This is an Xbox” pazarlama kampanyasının rafa kaldırılması hakkında konuştu

Microsoft,

Microsoft, "This is an Xbox" pazarlama kampanyasını sessiz sedasız rafa kaldırdıGeçtiğimiz ay Xbox dünyasında büyük bir değişim yaşandı. Phil Spencer emekliye ayrıldı, başkan Sarah Bond ise istifa etti. Bu dönüşümle ilgili olarak bazı bilgiler paylaşan Tom Warren, Xbox’ı bir konsol değil platform olarak konumlandıran “This is an Xbox” pazarlama kampanyasının arkasında Bond’un yer aldığını ve bu odağın şirket içindeki çoğu kişi tarafından hiç hoş karşılanmadığını aktarmıştı. Bond’un ayrılışının Xbox’taki çok sayıda çalışanı rahatlattığını belirten Warren, çalışanların Bond’la çalışmanın oldukça zor olduğunu da söylediğini ifade etmişti. Xbox’ın başında artık “Asha Sharma” yer alıyor ve Sharma’nın attığı ilk adımlardan biri “This is an Xbox” pazarlama kampanyasını rafa kaldırmak oldu. Bu kampanya için hazırlanan içerikleri Xbox’a yönelik haberlerin paylaşıldığı resmi blog sayfası Xbox Wire’dan silen Microsoft, bu sürpriz konuda bugün ilk kez kısa bir açıklama yaptı. Windows Central’a konuşan şirket sözcüsü kampanyanın iptal edilmesine yönelik olarak, “Asha bu kampanyayı Xbox gibi hissettirmediği için iptal etti. Şu anda Xbox’ın marka yapılanmasına bizzat liderlik ediyor.” […]

Microsoft, “This is an Xbox” pazarlama kampanyasının rafa kaldırılması hakkında konuştu yazısı ilk olarak Teknoloji haberleri – LOG üzerinde yayınlandı.

Uttar Pradesh: India’s Next Big Business Hub for Startups

Ever thought about where your next big business opportunity in India could be? Forget the usual suspects for a moment and turn your gaze towards a state that’s rapidly transforming into an economic powerhouse: Uttar Pradesh.

Yes, you read that right! Uttar Pradesh, often known for its rich history and culture, is now making headlines as a dynamic hub for startups and established businesses alike. If you’re an Indian startup or an entrepreneur looking for a fertile ground to grow, Uttar Pradesh offers an unmatched blend of market size, infrastructure development, and proactive government policies.

Why Uttar Pradesh is the Go-To State for Business Growth

What makes Uttar Pradesh so attractive right now? It’s a combination of several strategic factors that are creating an incredibly favorable business environment:

1. A Market Like No Other

With a population exceeding 240 million, Uttar Pradesh is not just India’s most populous state, but one of the most populous sub-national entities in the world. This translates into a colossal domestic market. Imagine the sheer demand for products and services! From consumer goods to digital services, the potential customer base in Uttar Pradesh is simply enormous, offering unparalleled scalability for businesses.

2. Infrastructure on an Accelerated Path

The state has witnessed an unprecedented infrastructure boom. We’re talking about:

  • Expressways: A network of world-class expressways (like Purvanchal, Bundelkhand, Ganga Expressways) is drastically improving connectivity, reducing logistics costs, and facilitating faster movement of goods.
  • Airports: Multiple international and domestic airports are either operational or under construction, connecting Uttar Pradesh to global and national markets with ease.
  • Industrial Corridors: Dedicated industrial areas, special economic zones (SEZs), and manufacturing clusters are being developed, offering ready-to-move-in facilities for businesses.
  • Power: Significant improvements in power supply ensure reliable electricity for industrial operations, a crucial factor for any growing business.

3. Pro-Business Policies & Ease of Doing Business

The government of Uttar Pradesh has been aggressively working to simplify the business landscape. Their focus on “Ease of Doing Business” is evident through:

  • Single-Window Clearance System: Streamlining approvals and reducing bureaucratic hurdles for investors.
  • Investor Summits: Regularly held events to attract domestic and international investment.
  • Sector-Specific Policies: Tailored policies for industries like IT, electronics manufacturing, food processing, and tourism, offering incentives, subsidies, and tax breaks.
  • Startup Policy: Specific initiatives to foster innovation, provide funding support, and create a robust startup ecosystem within Uttar Pradesh.

Key Sectors Ripe for Innovation in Uttar Pradesh

While the market is vast, some sectors stand out with immense growth potential:

Agriculture & Food Processing

As an agrarian state, Uttar Pradesh offers huge opportunities in agri-tech, food processing, cold chain logistics, and value-added agricultural products. The “One District One Product” (ODOP) scheme further promotes local specialties.

MSMEs & Manufacturing

The backbone of the Indian economy, MSMEs (Micro, Small, and Medium Enterprises) thrive here. With a large skilled and semi-skilled workforce, manufacturing across various segments from textiles to engineering goods sees significant potential.

Tourism & Hospitality

Home to iconic sites like the Taj Mahal, Varanasi, and Ayodhya, Uttar Pradesh is a global tourist magnet. Startups in travel tech, heritage tourism, hospitality services, and pilgrimage tourism can find a massive market.

IT & Startups

Emerging IT hubs in cities like Noida, Lucknow, and Ghaziabad, coupled with government incentives, are attracting tech startups. Opportunities exist in software development, AI, IoT, e-commerce, and digital services.

Tapping into the Talent Pool of Uttar Pradesh

Uttar Pradesh boasts a large and diverse talent pool, with numerous universities, engineering colleges, and vocational training institutes. Government initiatives focused on skill development are further enhancing the employability of this workforce, making it easier for businesses to find the right talent locally.

Your Next Big Move Could Be in Uttar Pradesh

For Indian startups and businesses, Uttar Pradesh is no longer just a state; it’s a strategic growth corridor. The combination of a massive market, rapidly improving infrastructure, and a pro-business government creates an ecosystem ripe for innovation and expansion. If you’re ready to scale your venture and tap into one of India’s most promising economic landscapes, it’s time to seriously consider making Uttar Pradesh your next business destination.

Don’t miss out on the incredible growth story unfolding in the heart of India. Explore the possibilities, engage with the local ecosystem, and watch your business flourish in Uttar Pradesh!

Flipping the Question: From ‘Is It Too Wet?’ to ‘Is It Too Dry?’

When I first built the Groundwise engine for the Ridewise app, it answered one question: is it too wet to ride? Low wetness meant good conditions. High wetness meant stay home. Exactly what I needed for mountain biking, skateboarding, and other outdoor wheeled activities where surface conditions impacted by weather mattered.

Then I started thinking about my garden.

I have raised beds, a few containers on the patio, and a lawn that I’d like to keep alive without drowning it. I don’t get obsessive about it, and to be honest my lawn is far from impressive. I’m not one of those people who try to maintain golf course grass, far from it. But I’ve still regularly done that assessment where I look at the sky, vaguely remember whether it rained, and make the same kind of gut call on watering that I used to make about trails. The variables were familiar: recent rain, temperature, sun exposure, how fast things dry. I was running the same mental model, just asking the opposite question.

That realization is what turned one app into three. If the engine could quantify moisture on a surface, it could answer both “is it too wet to ride?” and “is it too dry, should I water?”

The Inversion

The engine always calculates a wetness score from 0 (bone dry) to 1 (saturated). The EngineMode enum controls what that score means:

Wetness concern (Ridewise, Fieldwise): Low wetness = Yes (go ride or play). High wetness = No (too wet).

Dryness concern (Yardwise): Low wetness = Yes (water today). High wetness = No (skip watering).

The verdict is the same enum, Yes, Maybe, No, but the interpretation flips. This means the consuming app doesn’t need to know which mode produced the result. It renders green for Yes, orange for Maybe, red for No, regardless.

The thresholds shift too:

Yes Maybe No
Wetness concern < 0.30 0.30 – 0.60 ≥ 0.60
Dryness concern < 0.15 0.15 – 0.45 ≥ 0.45

Yardwise thresholds are lower because the stakes are asymmetric. This wasn’t immediately obvious until testing early versions of the app, when I realized that missing a watering day rarely if ever harms established plants. Riding a muddy trail damages the surface. So Yardwise is more lenient than the other apps, and leans toward “maybe check the soil” rather than “definitely water” — this makes the verdict less alarming. The reality is that the cost of a false positive (unnecessary watering) is real (overwatering causes its own problems), while the cost of a false negative (skipping one day) is usually trivial.

Manual Watering: Tracking What the Weather Doesn’t Know

Here’s where Yardwise diverges from Ridewise and Fieldwise most sharply. The weather API knows about rain. It doesn’t know that you ran the sprinkler for 20 minutes yesterday evening.

Yardwise lets users log manual watering events with timestamps. The engine converts these into precipitation equivalents that then get added to the rain score alongside actual precipitation, with the same time-weighted decay:

0-24 hours ago:  0.7x weight (70% contribution)
24-48 hours ago: 0.2x weight
48-72 hours ago: 0.1x weight

A watering yesterday contributes about 0.175 inches to the effective precipitation (0.25 × 0.7). That’s enough to shift a borderline “water today” verdict to “check soil” or even “skip.”

The decay is important. A watering three days ago shouldn’t prevent the engine from recommending water today, the moisture is long gone, especially in containers with fast-draining potting mix. The time-weighted model handles this naturally.

Cold Weather Extends Watering Memory

One wrinkle: cold weather slows evapotranspiration. A watering event that would normally decay in 48 hours can remain relevant for up to 96 hours during cold weather. The engine extends the effective window based on the same cold factor used for dormancy:

wateringWindow = 48 + 48 × coldFactor

At 34°F, the cold factor is about 0.62, giving a window of roughly 78 hours. At 45°F, it’s about 56 hours. This prevents the engine from recommending watering when yesterday’s water is still sitting in cold, slowly-evaporating soil. Keep in mind that winter watering is pretty rare, reserved almost exclusively for new plantings (trees, shrubs, etc.) and only during an unusual dry spell.

Establishment Sensitivity: Protecting Young Plants

Newly seeded lawns and recently transplanted plants need more water than established ones, and they’re far more susceptible to drought. The engine handles this through an establishment sensitivity boost that layers on top of the surface type’s base damage sensitivity.

Establishment Sensitivity Boost
Established +0.0 (default)
Newly planted +0.2
Newly seeded +0.5

This boost affects the sensitivity veto, the mechanism that pushes borderline verdicts toward protection. With a newly seeded lawn (base sensitivity 0.4 + boost 0.5 = 0.9 effective sensitivity), a Maybe verdict with wetness below 0.3 gets overridden to Yes: water those seeds.

For established plants with no boost, the same borderline conditions stay as Maybe: check the soil yourself. The engine doesn’t push you to water unless there’s a fragile surface that genuinely needs protection.

This is the same sensitivity veto that Fieldwise uses to protect natural grass athletic fields from being played on when borderline wet. Same mechanism, opposite direction. In wetness-concern mode, high sensitivity + borderline conditions → No (protect from use). In dryness-concern mode, high sensitivity + borderline conditions → Yes (protect from drought).

Dormancy and Evaporative Demand

Two Yardwise-specific wetness modifiers that were covered in the edge cases article deserve a brief recap in the context of the inversion:

Cold-weather dormancy injects additional wetness into the score when temperatures drop below 55°F. This suppresses watering recommendations by pushing the score toward the “skip” zone because dormant plants don’t benefit from watering and the moisture promotes root rot and fungal growth.

From the engine’s perspective, dormancy is saying: “the soil might technically be dry, but the plant doesn’t need water right now, so act as if conditions are wetter than they are.”

Low evaporative demand does something similar on cool, overcast, humid days. Even if the soil surface looks dry, the plant isn’t losing much water through transpiration when there’s no sun and the air is already saturated. A small wetness boost (up to 0.25) prevents unnecessary watering recommendations.

Both modifiers only activate in dryness-concern mode. They don’t make sense for riders or field sports, cold weather doesn’t make a trail less muddy, and overcast skies don’t affect whether the ground is rideable or a football field is playable.

Yardwise Surface Types

The Groundwise family shares a surface type system, but Yardwise introduces types that don’t exist in the riding or sports world:

Potting mix (containers) dries at 2.0x — the same rate as concrete. This isn’t a coincidence. Potting mix is engineered for drainage, just like concrete. Containers are also typically exposed to wind on all sides, which accelerates drying considerably. The result: containers often need daily watering in summer, even after rain. The engine captures this naturally because the high drying multiplier clears moisture quickly.

Amended soil with mulch dries at 0.7x — the slowest rate in the system, tied with clay. Mulch deliberately slows evaporation. A mulched garden bed after rain stays moist for days. The engine’s residual wetness model (which only activates for absorbent surfaces above 0.25 inches of rain) extends this further, keeping the score in “skip watering” territory well after a good rain.

Fresh seed has the highest effective damage sensitivity in the system: base 0.8 plus an establishment boost of 0.5, capped at 1.0. The engine is maximally protective of newly seeded areas, pushing any borderline verdict toward “water.”

Compost is the most forgiving surface: 1.2x drying multiplier and only 0.3 damage sensitivity. Compost generates internal heat from decomposition, which drives some of its own drying. And it’s hard to damage, overwatering a compost pile isn’t really a concern. The engine mostly stays out of the way for compost, skewing toward “skip” unless conditions are genuinely dry.

The Insight That Made It Work

The hardest part of building Yardwise wasn’t the code, it was convincing myself that the inversion was valid. Every instinct I’d built while developing Ridewise and Fieldwise said “wet = bad.” Retraining my thinking to “wet = good (for gardening)” took effort.

What made it click was realizing that the engine isn’t really about wet or dry. It’s about moisture state relative to the ideal for a given surface and activity. For a trail, the ideal is dry. For a garden bed, the ideal is moist (not soaked!). The engine measures distance from ideal in both directions.

Once I saw it that way, the inversion was mechanical. Same measurements, same model, same pipeline. Just a different definition of “good.”

Next in the Series

The final article covers the verdict UX, why three states beat a percentage, how the engine communicates uncertainty through confidence levels and contributing factors, and the role of the recovery outlook in managing expectations.

Yardwise is available for iOS from Stalefish Labs. Same engine as Ridewise and Fieldwise, different question.

The Great Talent Paradox of 2026: Why AI Is Making Developer Shortages Worse, Not Better

Everyone expected AI to solve the developer shortage.
Instead, it’s making it brutally worse.
50% of tech leaders now cite recruiting and retaining skilled technology workers as their #1 business challenge in 2026 — the highest it’s ever been. Over 90% of organizations globally face severe IT talent shortages, with potential economic losses exceeding $5.5 trillion. The gap isn’t just in raw headcount — it’s in the specific skills that actually matter: AI-native engineering, secure systems design, platform thinking, and the ability to manage fleets of AI agents without creating chaos.
The irony is painful. AI coding tools are everywhere (84% adoption), yet they haven’t reduced demand for humans. They’ve changed it.
The Data Behind the Paradox
Recent 2026 research paints a clear picture:

IDC and World Economic Forum data show 59% of workers will need reskilling by 2030, with 39% of existing skills becoming obsolete. Critical shortages hit cloud architecture, AI/ML, cybersecurity, and legacy modernization.
87.5% of tech leaders describe hiring engineers as “brutal.” Time-to-hire has stretched to 3–6 months for key roles, delaying infrastructure projects and piling pressure on existing teams.
AI hasn’t replaced developers — it’s created a “Talent Paradox”: Tools attract top performers but don’t create them. Senior engineers now spend most of their time reviewing AI-generated code instead of architecting, mentoring, or innovating. This leads to burnout, higher turnover, and even more pressure on the remaining talent.
The vicious cycle is real: Overworked teams burn out and leave → remaining staff handle more load → less time for training or upskilling → harder to hire because culture and velocity suffer.

In India (especially hubs like Pune, Bangalore, and Hyderabad), the pressure feels even sharper. Global clients demand AI-fluent delivery while local competition for skilled engineers remains fierce, and EMIs don’t wait for “reskilling time.”
Real Stories from the Front Lines

Engineering leaders report seniors are “burning out auditing AI pull requests” instead of doing high-value work.
Companies struggle to find people who can have “difficult conversations about technical debt with non-technical executives.”
Juniors ramp up faster on syntax thanks to AI, but lack systems thinking, security judgment, and architectural taste — skills that take years of guided experience.
The result? Delivery risk rises, brittle architectures emerge, and the best talent walks away from environments that feel like endless maintenance mode.

Why AI Amplified the Shortage
AI lowers the bar for producing code but raises the bar for responsible engineering. Organizations now need:

People who can verify, secure, and integrate AI output at scale.
Platform engineers who build golden paths and reduce cognitive load.
Leaders who balance velocity with sustainability.

Pure “coders” are easier to find. T-shaped engineers who combine deep fundamentals with AI fluency and soft skills? Much rarer.
Practical Solutions That Actually Work in 2026

Internal Upskilling at Scale — Create structured AI pair-programming rotations, targeted learning paths, and mentorship programs focused on judgment, not just prompting.
Platform Engineering as Retention Tool — Build Internal Developer Portals (IDPs) with self-service golden paths. This reduces toil and lets engineers focus on meaningful work — the #1 driver of retention.
Target the Right Profile — Hire for strong fundamentals + learning agility rather than pure LeetCode wizards. Look for people excited to work with AI agents.
Measure and Protect DevEx — Track real developer experience metrics (onboarding time, cognitive load, after-hours work) and act on them.
The Bottom Line
AI didn’t eliminate the need for great developers — it made great developers even more valuable.
The organizations winning in 2026 aren’t the ones trying to hire their way out of the shortage. They’re the ones investing in platforms, upskilling, and environments where talented people actually want to stay and grow.
You now have the latest data, real patterns, and a concrete tool to start closing the gap today.
The talent crisis doesn’t have to define your 2026.

How I Handle State Management in a Health App

State in a Breathing App Is Deceptively Complex

On the surface, Lunair seems like it would have simple state: a breathing pattern is playing or it is not. In reality, the state graph is surprisingly deep once you account for interruptions, background transitions, accessibility events, and user preferences.

The State Machine Approach

Early on I used a collection of boolean flags — isPlaying, isPaused, isComplete. This quickly devolved into impossible states. Could something be both paused and complete? The flags said yes; reality said no.

I switched to an explicit state machine:

enum SessionState: Equatable {
    case idle
    case preparing(pattern: BreathPattern)
    case active(phase: BreathPhase, cycleCount: Int)
    case paused(resumePhase: BreathPhase, cycleCount: Int)
    case completing(totalCycles: Int)
    case completed(summary: SessionSummary)
}

Every possible state is explicitly modeled. The paused case carries the information needed to resume. The completing state handles the wind-down animation before showing results. No impossible combinations.

Managing Transitions

State transitions are handled through a single method that validates and applies changes:

class SessionManager: ObservableObject {
    @Published private(set) var state: SessionState = .idle

    func transition(to newState: SessionState) {
        guard isValidTransition(from: state, to: newState) else {
            assertionFailure("Invalid transition: (state) -> (newState)")
            return
        }

        let oldState = state
        state = newState

        handleSideEffects(from: oldState, to: newState)
    }

    private func isValidTransition(
        from: SessionState, to: SessionState
    ) -> Bool {
        switch (from, to) {
        case (.idle, .preparing):           return true
        case (.preparing, .active):         return true
        case (.active, .paused):            return true
        case (.active, .completing):        return true
        case (.paused, .active):            return true
        case (.paused, .idle):              return true
        case (.completing, .completed):     return true
        case (.completed, .idle):           return true
        default:                            return false
        }
    }
}

The assertionFailure catches invalid transitions during development without crashing in production. This has caught bugs multiple times — especially around background/foreground transitions triggering unexpected state changes.

Side Effects at the Boundary

All side effects — haptics, sound, analytics, timer management — happen in handleSideEffects, not scattered throughout the UI:

private func handleSideEffects(
    from oldState: SessionState, to newState: SessionState
) {
    switch (oldState, newState) {
    case (_, .active(let phase, _)):
        hapticEngine.signalPhase(phase)
        timerManager.start(for: phase)

    case (.active, .paused):
        timerManager.pause()
        hapticEngine.stop()

    case (_, .completing(let cycles)):
        hapticEngine.signalSessionComplete()
        analytics.logSessionEnd(cycles: cycles)

    default:
        break
    }
}

This centralization means the views stay purely declarative. They read state and render. That is it.

Persisting Across Interruptions

Phone calls, notifications, backgrounding — all of these can interrupt a breathing session. The state machine handles this cleanly:

func handleScenePhaseChange(_ phase: ScenePhase) {
    switch phase {
    case .background:
        if case .active(let breathPhase, let count) = state {
            transition(to: .paused(
                resumePhase: breathPhase,
                cycleCount: count
            ))
        }
    case .active:
        // Don't auto-resume — let the user choose
        break
    default:
        break
    }
}

Auto-pausing on background is essential. Auto-resuming is not — returning to the app after a phone call should not immediately start a breathing cycle without the user being ready.

What I Would Do Differently

If I were starting Lunair today, I would reach for the Observation framework (@Observable) sooner. The ObservableObject + @Published pattern works, but the newer approach eliminates unnecessary view updates when unrelated published properties change.

The state machine pattern, though, I would not change. It has been the single most stabilizing architectural decision in the entire app.