Shahzad Bhatti Welcome to my ramblings and rants!

September 28, 2026

Governing the AI Factory: How to Ship Fast Without Derailing the Release Train

Filed under: Computing — admin @ 4:51 pm

Here’s how to define where AI fits, reduce cognitive and intent debt, and keep your delivery pipeline stable.


1. The AI Genie

AI coding agents can produce code at an incredible speed but without the quality, you will find that it quickly derails the release train. The existing CI and code-review processes were designed for human-paced changes but this doesn’t keep up when you are pushing code at 10x and you end up with the release problem.

In my experience, AI code velocity increases change failure rate, build times, merge conflicts, flaky tests and release incidents. Here is how some of the companies are trying to solve merge queue related problems:

SourceFinding
LinearCut PR wait time via faster runners, tsc -> tsgo, sparse checkout, additional test shards
Atlassian70+ large repos on merge queues, PR-level CI answers “does this work alone,” not “does this still work with everything”
NirvanaStateless speculative merge queue.
MergifySpeculative testing, batching, and scope-aware parallel lanes
AutonomaParallel AI subagents multiply conflict surface area unless generation is batched and merges serialized

Here is additional data I found on how AI is affecting the release process:

SourceFinding
DORA 2025PRs merged per developer +98%. Incidents per PR +243%. Bugs per developer +54%
Cortex 2026PRs per author +20% YoY. Incidents per PR +23.5%. Change failure rate ~30%
GitHub Octoverse 202492% of developers use AI coding tools. GitHub Actions CI/CD minutes up 169%
GitClear (211M lines)Code churn doubled. Refactored code fell 24.1% ? 9.5%. Duplicated blocks rose 8x
Uplevel (800 devs, 3 months)+41% bugs after Copilot adoption. No improvement in cycle time or throughput
Opsera (250k+ devs, 60+ orgs)AI PRs sit 4.6x longer in review queues; duplication 10.5% ? 13.5%

Joe Magerramov’s post shows how to build a small Monte Carlo simulator for modeling merge queues. Joe showed how CI/CD becomes a traffic jam, not just a queue. CI/CD batches are cumulative so one defect forces a revert and next batch is affected. I built my own simulator based on Joe’s model (see https://github.com/bhatti/simulators) with additional support for the batched PRs release. It models defect rate with the pipeline duration at 100 commits per day:

The key lesson is that you need to either lower the defect rate per commit or shorten pipeline duration. This is not an easy task, I have encountered a large pipeline duration at many organizations due to large mono-repos, a large codebase with millions of LOC, and large test suite with a long vaidation cycle. The AI agents makes it worse with parallel changes that might conflict resulting in cognitive and intent debt for engineers because no one can track all changes. I explained some of these concepts in my earlier blog and showed how learning feedback loops can be used to build resilient software factories. In order to build end to end agentic SDLC process, you need to define what is AI responsible for and what are the roles for humans.

QuadrantRole
Human Real-timeDecisions that require judgment, context, accountability
Human AsyncReview that needs thought but not immediacy
AI Real-timeAssistance that augments human work in the moment
AI BackgroundAutonomous work that runs without blocking humans

2. The Architecture

I built an orchestration engine Formicary to create data pipelines and CI/CD processes many years ago but I have been using it for driving AI driven workflows. It defines simple primitives to build DAG tasks with exit-code routing, artifact handoff, and fan-out. Here are a few approaches that I am using with the AI driven workflows:

  • Speculative merge queue (test PR N as if N-1 already merged)
  • Scope-aware parallel lanes
  • Incremental builds, test-impact analysis, sparse checkout
  • Deterministic gates a green build can’t waive
  • Independent AI review
  • Contract testing + canary
  • Formal verification of queue invariants (TLA+, Dafny)
  • API fuzz testing
  • Learning flywheel: merge -> extract learnings -> next run reads them -> audit proposes skill changes

Here is how I use Formicary with a CLI toolkit and skillsThe system is split across three repos, each with a clear responsibility boundary:

Here are the core design principles

  • Declarative DAG with a task block that uses on_exit_code for routing
  • Harness + sandbox + skills as separate layers
  • File-based state handoff
  • The learning flywheel closes the loop on review time and merge-time

2.1 Scope router: ai-scope-router

Runs right after create-pr in the existing pipeline and computes a scope key from touched paths. The backing script (scripts/mq/scope_router.py) computes blast radius from line counts and module count, and labels the PR.

# ai-scope-router.yaml (excerpt — full file in docs/examples/)
job_type: ai-scope-router
max_concurrency: 20
timeout: 600s

tasks:
- task_type: classify
  method: KUBERNETES
  script:
    - python -m scripts.mq.scope_router --pr-number {{.PRNumber}}
    - python -m scripts.mq.risk_score --pr-number {{.PRNumber}}
  on_completed: route

- task_type: route
  script:
    - |
      python3 -c "
      import json, os
      scope = json.load(open('/workspace/scope.json'))
      risk = json.load(open('/workspace/risk_score.json'))
      decision = {
          'scope_key': scope['scope'],
          'risk_tier': risk['tier'],
          'lane': scope['scope'],
          'requires_approval': risk.get('requires_human_approval', False)
      }
      json.dump(decision, open('/workspace/route_decision.json', 'w'), indent=2)
      "
  on_completed: done

Here’s what the Slack report looks like for a PR:

# PR Review Report — PR #4091

## Review Findings

? No issues found

## Risk Score

? MEDIUM (score 25.5/100) — Standard review needed; test independently before merge

| Dimension       | Score | Weight | Evidence                                          |
|-----------------|-------|--------|---------------------------------------------------|
| Size            | 7/10  | 1.5×   | 219 lines (+147/?72)                              |
| File Count      | 2/10  | 1.0×   | 5 files changed                                   |
| Blast Radius    | 5/10  | 2.0×   | blast=medium, scope=payments-service              |
| Sensitive Paths | 0/10  | 2.5×   | no sensitive files detected                       |
| Test Coverage   | 0/10  | 1.5×   | good coverage (test:source ?1:1)                  |
| Historical      | 3/10  | 1.0×   | ?? no defect history available — neutral default   |



## Scope

| Field          | Value            | Description                                                    |
|----------------|------------------|----------------------------------------------------------------|
| Scope          | payments-service | All changes owned by payments-service — can merge in dedicated lane |
| Blast radius   | medium           | Moderate change (51–300 lines or 2 modules) — test independently |
| Changed files  | 5                | Number of files modified in this PR                            |
| Lines changed  | 219              | Total additions + deletions                                    |
| Owners         | @payments-team   | CODEOWNERS entries responsible for review                      |

2.2 Risk-gated review: ai-gate-review

The RADAR-style funnel where AI reviews every PR, computes a risk score, and produces a report.

# ai-gate-review.yaml — read-only pipeline
# review ? gate-check ? done (no merge, no approve, no PR comments)
- task_type: gate-check
  script:
    - |
      python3 -c "
      risk = json.load(open('/workspace/risk_score.json'))
      review = json.load(open('/workspace/review_result.json'))
      needs_approval = risk['score'] >= threshold or has_critical_findings
      # Writes gate_result.json — read-only, no PR changes
      print(f'Gate: {\"needs-approval\" if needs_approval else \"safe-to-merge\"}')"

Low-risk PRs are flagged “safe-to-merge” in the report. High-risk ones are flagged “needs-approval” with the specific reason. The risk score itself is a RADAR-style weighted composite across six dimensions (scripts/mq/risk_score.py).

2.3 The merge queue core

As a Formicary DAG it uses real fan-out with fork_job_type where each scope lane runs as its own child job:

# ai-merge-queue.yaml (excerpt)
job_type: ai-merge-queue
cron_trigger: "0 2 * * *"   # once daily at 2am; bump frequency when queue fills up
max_concurrency: 1

tasks:
- task_type: collect
  environment:
    TARGET_BRANCH: "{{.TargetBranch}}"   # filter to PRs targeting this branch
  script:
    - python -m scripts.mq.collect_ready   # reads TARGET_BRANCH from env

- task_type: group
  script:
    - python -m scripts.mq.group_by_scope   # produces hierarchical risk-tier lanes

- task_type: analyze
  script:
    - python -m scripts.mq.analyze --skill ygs-merge-queue

- task_type: report
  script:
    - mkdir -p /workspace/reports
    - python -m scripts.mq.report

Invoke from Slack with a target branch:

@bot mq --target stage          # short alias
@bot mq --target prod --repo org/my-repo

Each ai-mq-lane child job runs independently:

2.4 Work type distribution

The simulation discussed earlier has two knobs: defect rate and pipeline duration. The MQ report measures this from an actual PR data. For example, work type classification breaks every open PR into one of eight types: feature, bug, security, refactor, chore, test, docs, or unknown. It then measures defect rate, bug ratio, chore+refactor fraction.

What the report shows:

### Work Type Distribution
| Type      | Count | %     | Signal                      |
|-----------|-------|-------|-----------------------------|
| ? feature | 45    | 18.0% | new functionality           |
| ? bug     | 22    | 8.8%  | defect indicator            |
| ? security| 5     | 2.0%  | defect indicator (security) |
| ?? refactor | 30    | 12.0% | tech debt reduction         |
| ? chore   | 15    | 6.0%  | maintenance / KTLO          |
| ? test    | 120   | 48.0% | quality investment          |
| ? docs    | 3     | 1.2%  | documentation               |
| ? unknown | 10    | 4.0%  | unclassified                |

> ? Defect rate proxy: 10.8% (27 bug+security PRs / 250 total) — ~1-in-9.
> At batch size ~10, est. batch success ? 31% (moderate defect rate).
> Feature:Bug ratio = 1.7:1 — below 3:1, team spending significant effort on defect repair.

3. Quality at the Source

The defect-rate knob from earlier simulation is the hard to move but it is a high impact knob. Teams that have adopted agentic engineering at scale share a common pattern: an 8-phase SDLC that wraps AI capabilities with human judgment.

3.1 Structured specs reduce defect rate at source

Every ticket entering a sprint needs five things before an agent touches it:

  1. Why: one sentence on the customer problem
  2. Scope boundaries
  3. Aacceptance criteria in given/when/then form
  4. Definition of Done
  5. Environment matrix

An AI skill generates the structured ACs; a human validates and edits.

3.2 Design docs

Not every change needs a design doc. The decision tree:

SignalAction
New architecture pattern, public API change, multi-team impact, >1 sprint, auth/securityWrite a design doc
Bug fix, tests/config only, simple refactor in one file, dependency bumpNo design doc needed

When required, the design doc must address: risks, rollback strategy, MVP scope, testing strategy, and observability.

3.3 Tiered review

Not all PRs deserve the same review depth. A risk-based triage:

SignalsTier
Small diff, tests or config only, simple refactor, pre-PR gate passed cleanlyTier 1 Light pass
Auth/tokens/permissions, new or changed REST endpointTier 2 Full review

3.4 Contract testing + fuzz testing

The DORA/LeadDev findings showed that the contract testing before adopting AI reduces change failure rate. Contract testing catches the “clean-looking PR, silent cross-system break” failure mode.

I have another open source project api-mock-service, that can be used for contract and fuzz testing, e.g.,

# 1. Load an OpenAPI spec (or record live traffic through the proxy)
curl -X POST http://localhost:8080/_oapi -F "file=@openapi.yaml" -F "group=billing"

# 2. Run producer contract tests
curl -X POST "http://localhost:8080/_contracts/billing?baseUrl=http://billing:8080&executionTimes=3"

# 3. Run mutation tests (11 strategies)
curl -X POST "http://localhost:8080/_contracts/mutations/billing?baseUrl=http://billing:8080&executionTimes=5"

# 4. Export JUnit XML for CI
curl "http://localhost:8080/_contracts/billing/junit" > contract-results.junit.xml

3.5 Mutation strategies

Per-field mutations test individual field validation:

StrategyWhat it tests
Missing fieldsRequired field validation
Boundary valuesMin/max, empty strings, zero, MAX_INT
Malformed dataWrong types, invalid formats
Null fieldsNull handling, NPE prevention
Combinatorial nullsMulti-field invalid combinations
Format-specific boundariesDate edges, URL length, email format
Security injectionsSQLi, XSS, path traversal, SSTI, cmd injection, NoSQLi

Sequence-level mutations test stateful interactions:

StrategyWhat it tests
Request reorderingState machine correctness
Request duplicationIdempotency
Request omissionRequired step enforcement
Timing variationsRace conditions, timeouts

3.6 Contract testing as a Formicary job

The Formicary pipeline runs as a three-task Formicary DAG (docs/examples/ai-contract-test.yaml). Each task runs in a separate Kubernetes pod with api-mock-service as a sidecar:

job_type: ai-contract-test
description: "Contract validation + security fuzzing via api-mock-service proxy"
max_concurrency: 5
timeout: 3600s

variables:
  PRNumber:
    type: STRING
    required: false
  ServiceURL:
    type: STRING
    required: false
  Service:
    type: STRING
    required: false

tasks:
# --- RECORD ------------------------------------------------------------------
- task_type: record
  method: KUBERNETES
  timeout: 15m
  report_stdout: true
  host_network: true
  working_dir: /workspace
{{if .Service}}
  services:
    - name: "{{default "service-under-test" .ServiceName}}"
      alias: "{{default "service-under-test" .ServiceName}}"
      image: "{{.Service}}"
      memory_limit: "{{default "4G" .ServiceMemoryLimit}}"
      cpu_request: "{{default "250m" .ServiceCpuRequest}}"
    - name: api-mock-service
      alias: api-mock-service
      image: plexobject/api-mock-service:latest
      ports:
        - number: 8081
      memory_limit: "512Mi"
      cpu_request: "100m"
      command: ["/api-mock-service", "--httpPort", "8081", "--proxyPort", "8082", "--dataDir", "/workspace/recordings"]
      volumes:
        empty_dir:
          - name: workspace
            mount_path: /workspace
{{end}}
  container:
    image: plexobject/ai-dev-tools:latest
    image_pull_policy: Always
    cpu_request: "250m"
    memory_limit: 512Mi
    memory_request: 256Mi
    volumes:
      empty_dir:
        - name: workspace
          mount_path: /workspace
    env_from:
      - secret_ref: ai-dev-credentials
  environment:
    WORKSPACE_DIR: /workspace
    SERVICE_URL: "{{.ServiceURL}}"
    SERVICE_PORT: "{{default "8080" .ServicePort}}"
    MOCK_SERVICE_PORT: "{{default "8081" .MockServicePort}}"
    PROXY_PORT: "{{default "8082" .ProxyPort}}"
    AI_DEV_TOOLS_DEBUG: "{{.AiDevToolsDebug}}"
  script:
    - python -c "from scripts.common.bootstrap import ensure_debug_mode; ensure_debug_mode()" 2>/dev/null || true
    - touch /tmp/.adt_bootstrap_done
    - python -m scripts.contract.record
  artifacts:
    paths:
      - ./record_result.json
      - ./recordings
    expire_after: 24h
  on_completed: fuzz
  on_failed: notify-error

# --- FUZZ --------------------------------------------------------------------
- task_type: fuzz
  method: KUBERNETES
  timeout: 20m
  report_stdout: true
  host_network: true
  working_dir: /workspace
{{if .Service}}
  services:
    - name: "{{default "service-under-test" .ServiceName}}"
      alias: "{{default "service-under-test" .ServiceName}}"
      image: "{{.Service}}"
      memory_limit: "{{default "4G" .ServiceMemoryLimit}}"
      cpu_request: "{{default "250m" .ServiceCpuRequest}}"
    - name: api-mock-service
      alias: api-mock-service
      image: plexobject/api-mock-service:latest
      ports:
        - number: 8081
      memory_limit: "512Mi"
      cpu_request: "100m"
      command: ["/api-mock-service", "--httpPort", "8081", "--proxyPort", "8082", "--dataDir", "/workspace/recordings"]
      volumes:
        empty_dir:
          - name: workspace
            mount_path: /workspace
{{end}}
  container:
    image: plexobject/ai-dev-tools:latest
    image_pull_policy: Always
    cpu_request: "500m"
    memory_limit: 2G
    memory_request: 512Mi
    volumes:
      empty_dir:
        - name: workspace
          mount_path: /workspace
    env_from:
      - secret_ref: ai-dev-credentials
  dependencies:
    - record
  environment:
    WORKSPACE_DIR: /workspace
    SERVICE_URL: "{{.ServiceURL}}"
    SERVICE_PORT: "{{default "8080" .ServicePort}}"
    MOCK_SERVICE_PORT: "{{default "8081" .MockServicePort}}"
    PR_NUMBER: "{{.PRNumber}}"
    AI_DEV_TOOLS_DEBUG: "{{.AiDevToolsDebug}}"
  script:
    - python -c "from scripts.common.bootstrap import ensure_debug_mode; ensure_debug_mode()" 2>/dev/null || true
    - touch /tmp/.adt_bootstrap_done
    - python -m scripts.contract.fuzz
  artifacts:
    paths:
      - ./fuzz_result.json
      - ./fuzz_results.xml
      - ./contract_test_summary.json
    expire_after: 24h
  on_completed: report
  on_failed: report

The recording proxy is a sidecar container (plexobject/api-mock-service:latest) that shares the pod network namespace. Traffic flows:

test script ? localhost:8081 (proxy) ? localhost:8080 (service under test)
                                ?
                    api_contracts/**/*.yaml   (saved per HTTP interaction)

3.7 Skills

Two complementary skills in the you-got-skills library:

  • /ygs-contract-test: Detects API surface, sets up api-mock-service, runs contract validation and mutation testing.
  • /ygs-fuzz-test: Generates fuzz corpus from API specs, applies all mutation strategies plus 8 CWE-classified injection classes.

Here’s the fuzz skill’s decision table for endpoint discovery:

Signal foundAction
OpenAPI/Swagger specParse endpoints + schemas directly
Express routes (app.get/post)Extract route patterns from code
Flask decorators (@app.route)Extract route patterns from code
Go http.HandleFuncExtract route patterns from code
Spring @RequestMappingExtract route patterns from code
None of the aboveBLOCKED — no endpoints to fuzz

The 8 CWE-classified injection classes the fuzz skill exercises:

Injection classCWEExample payload
SQL injectionCWE-89' OR 1=1 --
XSSCWE-79<script>alert(1)</script>
Path traversalCWE-22../../etc/passwd
SSTICWE-1336{{7*7}}
Command injectionCWE-78; cat /etc/passwd
NoSQL injectionCWE-943{"$gt": ""}
LDAP injectionCWE-90`)(uid=))(
XXECWE-611<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>

3.8 Live example: OWASP WrongSecrets

OWASP WrongSecrets is a Java Spring Boot application intentionally seeded with secrets management vulnerabilities.

Trigger from Slack:

@sb-slack contract-test https://github.com/OWASP/wrongsecrets \
  --service jeroenwillemsen/wrongsecrets:latest-no-vault

The job produces output such as:

[contract] api-mock-service ready at http://localhost:8081/_health (HTTP 404)
[fuzz] contracts: succeeded=0 failed=0
[fuzz] recordings_dir=/workspace/recordings exists=True contracts_dir_exists=True
      yaml_count=21 first_3=[
        '/workspace/recordings/api_contracts/GET/Recordedroot--200-c1ec01cf.yaml',
        '/workspace/recordings/api_contracts/status/GET/...',
        '/workspace/recordings/api_contracts/debug/GET/...'
      ]
[fuzz] discovered 21 endpoints: [
  ('GET', '/status'), ('GET', '/debug'), ('GET', '/metrics'),
  ('GET', '/openapi.json'), ('GET', '/docs'), ('GET', '/'),
  ('GET', '/swagger'), ('GET', '/challenge/21'), ('GET', '/challenge/28'),
  ('GET', '/challenge/1'), ('GET', '/health'), ('GET', '/api'),
  ('GET', '/api/challenges'), ('GET', '/api/Challenges'), ('GET', '/admin'),
  ('GET', '/v1'), ('GET', '/actuator/env'), ('GET', '/actuator/info'),
  ('GET', '/actuator/beans'), ('GET', '/actuator/health'),
  ('GET', '/actuator/mappings')
]
[contract] probe sqli:/ ? 200 finding=False
[contract] probe path_trav:/ ? 404 finding=False
[contract] probe sqli:/status ? 404 finding=False
[contract] probe path_trav:/status ? 404 finding=False
... (40 probes total) ...
[fuzz] 21 endpoints, 0 findings, 0 critical
[fuzz] complete: iterations=40 findings=0 critical=0 status=PASS ams_used=True

3.9 What a skill looks like: /ygs-contract-test

Skills are structured Markdown files in the you-got-skills library. Here’s the decision table from /ygs-contract-test:

```yaml
# SKILL.md frontmatter
---
name: ygs-contract-test
description: API contract testing — record, derive contracts, detect breaking changes
argument-hint: "<service-or-pr> [--mode record|validate|diff]"
---
```

```markdown
## Step 1: Determine contract testing mode

| Mode     | When to use                          | What happens                        |
|----------|--------------------------------------|-------------------------------------|
| record   | First time, or updating baseline     | Run tests through proxy, capture    |
| validate | PR review, CI gate                   | Compare against existing contracts  |
| diff     | Breaking change detection            | Compare two contract versions       |

Default to `validate` if a baseline exists, otherwise `record`.

## Step 2: Record API interactions
  api-mock-service --mode record --proxy-port 8081 ...
  HTTP_PROXY=http://localhost:8081 pytest tests/integration/

## Step 3: Derive and validate contracts
  curl -X POST "http://localhost:8080/_contracts/{group}?baseUrl=..."

## Step 4: Run mutation testing (11 strategies)
  curl -X POST "http://localhost:8080/_contracts/mutations/{group}..."

## Step 5: Export and report
  Report DONE if catch rate > 80%. BLOCKED if contracts fail.
  Suggest: /ygs-fuzz-test for deeper coverage, /ygs-security-review for findings.

4. Speed at the Pipeline

The second knob from earlier simulation is pipeline speed, which is a cheaper lever. This section covers formal verification of the queue protocol itself, build/test optimization with real numbers.

4.1 Formal verification

Tests check specific inputs but formal verification proves properties hold over all possible inputs. For a concurrent system like a merge queue, the state space is too large to test exhaustively.

TLA+ specification of the merge queue

The spec lives at docs/examples/specs/merge_queue.tla and it models the core merge queue as a state machine:

Safety for Scope isolation

ScopeIsolation ==
    \A s1, s2 \in Scopes :
        s1 /= s2 => lanes[s1] \cap lanes[s2] = {}

Safety for Test before merge:

TestBeforeMerge ==
    \A pr \in PRs :
        prState[pr] = "merged" => testResults[pr] = "pass"

Liveness to ensure every PR eventually merges or is ejected:

Progress == \A pr \in PRs :
    prState[pr] = "queued" ~> (prState[pr] = "merged" \/ prState[pr] = "ejected")

Dafny verification of merge invariants

The Dafny spec at docs/examples/specs/verified_merge.dfy proves four properties at compile time: batch merging preserves main branch health, bisection correctly partitions PRs, risk scoring is monotonic, and shard partitioning loses no tests:

predicate MainBranchHealthy(mergedPRs: set<PR>)
{
  forall pr :: pr in mergedPRs ==> pr.testResult == Pass
}

lemma MergeBatchPreservesHealth(mainBranch: set<PR>, batch: set<PR>)
  requires MainBranchHealthy(mainBranch)
  requires forall pr :: pr in batch ==> pr.testResult == Pass
  ensures MainBranchHealthy(mainBranch + batch)
{
  // Proof is automatic: union of two sets where all elements
  // satisfy the predicate still satisfies the predicate.
}

4.2 Test-impact analysis: scripts/mq/test_impact.py

The default behaviour is deliberate: always run the full test suite, partitioned into balanced shards. Diff-scoped runs are opt-in via --diff-scope and they require an explicit --head <branch> to define what to diff against.

# Default: full suite, 8 shards, no diff analysis
python -m scripts.mq.test_impact --pr-number main --num-shards 8

# Explicit diff-scope: numeric PR (base branch auto-extracted from GitHub API)
python -m scripts.mq.test_impact --pr-number 42 --num-shards 8 --diff-scope

# Explicit diff-scope: branch name (must supply --head as base to compare against)
python -m scripts.mq.test_impact --pr-number feature/my-branch \
    --head main --num-shards 8 --diff-scope

The script performs following operation:

  1. Fetches changed files via gh pr view --json
  2. Detects language from file extensions (Python, Go, TypeScript, Java, Kotlin, Ruby, C#, Rust)
  3. Maps to test files using naming conventions
  4. Traverses imports one level deep to find transitive dependents
  5. Partitions into balanced shards using greedy bin-packing with historical timing data
  6. Falls back to the full suite if no tests map to changed files

4.3 Parallel fan-out: ai-parallel-test

Test-impact analysis feeds directly into Formicary’s fan-out for parallel shard execution (docs/examples/ai-parallel-test.yaml):

# analyze task: runs test_impact.py, emits ::add-job-context TestShards::
- task_type: analyze
  script:
    - python -m scripts.mq.clone_pr --pr-number {{.PRNumber}}
    - python -m scripts.mq.test_impact --pr-number {{.PRNumber}}
    # TestShards is now in job context — fan-out reads it directly

# Fan-out: one task per test shard, running in parallel
# 4 CPU cores + 16G memory per shard ? real intra-shard parallelism
- task_type: run-tests
  container:
    cpu_request: "4000m"
    memory_limit: 16G
  fan_out:
    source: TestShards
    item_var: shard
    max_parallel: {{.MaxShards}}  # unquoted integer — YAML strict types
    fail_fast: false
  script:
    - python -m scripts.mq.clone_pr --pr-number {{.PRNumber}}
    - python -m scripts.mq.run_scoped_ci --shard "{{.shard}}"

Each shard runs independently on its own Kubernetes pod. The polyglot test runner (scripts/mq/run_scoped_ci.py) detects the project type from marker files and builds the right command.

Here is a sample output:

## Test Impact Analysis
**191** / 191 tests selected (**0%** reduction) across **2** shards

## Test Results
? **87** / 87 passed
  ?? 130s wall clock across 2 shards

### Shard Performance
| Shard | Tests | Passed | Failed | Duration | Status |
|-------|-------|--------|--------|----------|--------|
| 1     | 44    | 44     | 0      | 130.5s   | ?     |
| 0     | 43    | 43     | 0      | 115.4s   | ?     |

> **Parallel speedup:** 246s sequential ? 130s parallel (1.9x across 2 shards)

### Slowest Tests
| Duration | Test                          |
|----------|-------------------------------|
| 30.01s   | `TestInitEnabled`             |
| 30.01s   | `TestInitEnabled`             |
| 10.02s   | `Test_ShouldAsyncWithSleep`   |
| 10.01s   | `Test_ShouldAsyncWithSleep`   |
|  4.68s   | `Test_EncryptDecrypt`         |
|  2.45s   | `Test_ShouldRealGet`          |
|  2.28s   | `TestSharedSubscription`      |

### Test Health Insights
- ? Pass rate: 100% (87 tests)
- ? Shard balance: 12% imbalance (115s – 130s)
- ? 4 slow tests likely using real sleeps — consider mocking time or
  reducing timeouts: `TestInitEnabled` (30.0s), `Test_ShouldAsyncWithSleep` (10.0s)
- ?? 3 slow test names appear in multiple shards (possible test duplication):
  `TestInitEnabled`, `Test_ShouldAsyncWithSleep`, `TestSharedSubscription`

4.4 Deployment risk

The merge queue optimizes everything up to the point where the commit lands on main but merged PRs still have to ship. The original simulation from Joe Magerramov’s formula models the probability that a merge batch is clean and continuous delivery. In my simulation I added support for batch PRs as it’s used in many organizations. But this also increases risk, e.g., a merge queue with a 95% batch success rate can still lead to a weekly release train that ships clean only 36% of the time. The formula extends naturally:

release_success = (1 - defect_rate) ^ prs_per_release

CD is the special case where prs_per_release = 1, e.g.,

Defect rateCD (1 PR)Daily (10 PRs)Weekly (50 PRs)Bi-weekly (100 PRs)
0.5%99.5%95.1%77.8%60.6%
1%99.0%90.4%60.5%36.6%
2%98.0%81.7%36.4%13.3%
5%95.0%59.9%7.7%0.6%

At 2% defect rate, a CD pipeline has a 98% clean deployment rate. The same team on a weekly train: 36%. Two-thirds of their releases ship with at least one defect.

The rollback trap

Suppose release R1 ships with a hidden bug. By the time monitoring detects it, R2 and R3 have already deployed. Rolling back to pre-R1 means reverting R2 and R3 too.

Stacked releasesRollback feasibilityRecovery strategyMTTR multiplier
1? StraightforwardRollback1.0x
2-3?? Costly but possibleRollback (with intermediate revert)1.5x
?4? ImpracticalRoll-forward (fix-forward only)2.5x

One bad PR poisons the entire train and the release sits in an unknown state while someone bisects 50 changes to find the culprit.

Deployment maturity

Following are best practices from the industry for improving deployment maturity:

DimensionWeightWhat it enables
Automated testing2.0xCI on every PR catches defects pre-merge
Canary deployment2.0xProgressive rollout catches prod-only failures
Automated rollback1.5xInstant revert on anomaly detection
Observability1.5xAlerting + metrics detect failures fast
Wave deployment1.0xStage ? preprod ? prod progression
Feature flags1.0xDecouple deploy from release
Blue/green0.5xZero-downtime deploy infrastructure

The maturity score translates into a risk multiplier — a low multiplier means your infrastructure catches most defects before they reach users:

adjusted_success = release_success + (1 - release_success) × (1 - risk_multiplier)

The MQ report now shows two critical markers:

  • ? Blue line: your current position given your defect rate, batch size, and deployment maturity.
  • ? Red line: the calamity threshold: the batch size where success drops below 70%.

The calamity threshold formula:

max_safe_batch = log(0.70) / log(1 - defect_rate)

The deployment risk model points to three levers:

  1. Reduce batch size. Move from weekly to daily trains.
  2. Invest in deployment maturity. Automated testing (weight 2.0x) and canary deployment (weight 2.0x) are the two highest-leverage dimensions.
  3. Prevent release stacking. Each additional stacked release multiplies recovery cost.

5. The Learning Loop

5.1 Multi-agent coordination

The merge queue is naturally an actor system where each PR is an actor with a lifecycle (queued ? batched ? testing ? merged/ejected). The properties that matter at scale:

  • Location transparency: a PR doesn’t care which CI runner tests it
  • Supervision: when a batch fails, the supervisor (bisect task) localizes the fault and recovers
  • Isolation: scope-aware lanes are isolation boundaries

Better decomposition

Similar to the microservice architecture, the path forward is better decomposition:

  • Smaller PRs
  • Better module boundaries
  • Formal verification
  • Automated scope detection
  • Dependent PR grouping, e.g., group related PRs so if one fails, the entire group is ejected.

5.2 The skills improvement flywheel

5.3 Metrics

The four DORA keys (deployment frequency, lead time, change failure rate, failed deployment recovery time) are table stakes. At agent-scale, you need additional metrics like:

  • PR size
  • PR pickup time Stale PRs conflict more often and carry higher bisection cost in the merge queue.
  • AI PR acceptance rate.
  • Rework rate.
  • Cycle time decomposition break first-commit-to-deploy into pickup, review, merge, deploy sub-phases.

5.4 Path forward

The bottom line from all the evidence in this post:

  1. Don’t adopt AI at agent-scale before your delivery system can handle agent-scale quality.
  2. Pipeline speed is the cheaper lever. Defect rate has diminishing returns; pipeline duration has an order of magnitude of headroom.
  3. The merge queue is not a queue. It’s a coordination problem: scope-aware routing, risk-gated review, speculative batching, bisection-based fault recovery, and a learning loop.
  4. Formal verification catches what tests can’t. For concurrent protocols, tests check paths and TLA+ checks invariants.
  5. Specs reduce defects at source. Structured acceptance criteria, design docs for high-impact changes, and tiered review based on blast radius.

6. Getting Started

Everything above is code in three repos: formicary (workflow engine), ai-dev-tools (Python scripts), and you-got-skills (Claude skill library). Here’s how to test each piece.

Prerequisites

# 1. Deploy Formicary to Kubernetes (single-container: queen + ant + storage)
kubectl apply -f k8s.yaml
kubectl port-forward svc/formicary 7777:7777 19000:19000

# 2. Get an API token from the UI: http://localhost:7777 ? Profile ? API Token
export FORMICARY_TOKEN="<jwt>"
export GH_TOKEN="<github-pat>"
export SLACK_CHANNEL="your-channel"

# 3. Deploy all workflows + set configs
cd docs/examples
./deploy-ai-workflows.sh --create-k8s-secret --set-configs \
  --gh-org bhatti --gh-repo todo-api-errors --bedrock

Test 1: Scope router

In Slack, mention the bot in your configured channel:

@bot scope main --repo https://github.com/org/repo

This prompt triggers ai-scope-router workflow and then creates a report showing the scope, blast radius, coverage, historical data for defects, security and other metrics.

Test 2: Parallel test with test-impact analysis

@bot parallel-test --repo https://github.com/org/repo main
@bot parallel-test --repo https://github.com/org/repo --branch develop

This prompt triggers ai-parallel-test workflow that fans out multiple shards for parallel tests and then creates a report showing the selected vs total tests, reduction percentage, per-shard pass/fail counts, wall-clock time.

Test 3: Risk-gated review

@bot gate-review https://github.com/org/repo/pull/1

This prompt triggers ai-gate-review workflow and generates a report showing blast radius, security, test coverage and other metrics.

Test 4: Contract testing

@bot contract-test 1 --service myorg/my-api:latest

This prompt triggers ai-contract-test workflow and executes contract test against the target service. It then generates a report with the pass/fail counts, mutation catch rate, security findings (CWE-classified)

Test 5: Merge Queue Analysis

@bot mq https://github.com/org/repo

This prompt triggers ai-merge-queue workflow, evaluating health of the merge queue and calculating the risk/blast radius/complexity of all open PRs. Here is a sample snippet from the merge queue report:

? 250 open PRs across 22 scope lanes — repo: acme/platform

MetricValue
Total open PRs250
Scope lanes22
High-risk PRs140
Needs human review148
Conflict risk lanes1

?? 148 PR(s) flagged for human review before merge (high blast-radius, failed CI, or sensitive paths detected).

?? 1 lane(s) have conflict risk multiple PRs touching overlapping paths. Merge one at a time.

Overall status: ? Unstable (basis: 74.8% PRs aged >48h — CI status unavailable from API)

MetricValue
Total PRs in queue250
CI failure rateN/A — not reported by API (verify in CI dashboard)
PRs aged >48h187 (74.8%)
High blast-radius PRs140
Medium blast-radius PRs82
Approx batch size (PRs/lane)10.0

Unstable: 74.8% of PRs are aged >48h. Queue is filling faster than it drains — unblock stale PRs or shrink batch size.

CategoryPRsBug PRsHigh-BlastHotspot
? ui12211??? yes
? data323??? yes
? api315??? yes
? authn_authz315??? yes
? security153??? yes
sre121??—
backend30??—
config10??—
unknown30——

Test 6: PR Audit

@bot pr-audit

This prompt triggers ai-gh-pr-audit workflow, evaluating the risk/blast radius/complexity of all merged PRs. Here is a sample snippet from the pr audit report:

#PRIssueAuthorTitleCatTypeBlastRiskLOCFilesCx
1#48401PROJ-44822jsmithAuthCoordinator for REST OAuth? authn_authz? securityhigh? 721,84723? high
2#47228AI-4931aleefix(ai): allow regional Bedrock pr…? data? bughigh? 5889214? medium
3#48768DL-2875mchenImplement more fine-grained IAM roles? authn_authz? featurehigh? 4563411? medium
4#48909—jdoeSSRF guard for external MCP connectorui? bugmedium? 323128? low
5#48827PLAT-16031kpatel[Flaky Test] fix race in bottlenec…api?? refactorlow? 12873? low
…

Related Reading

September 11, 2026

What Happens After the PR Merges: Building the Learning Loop Software Factories Are Missing

Filed under: Computing — admin @ 8:51 pm

Turning every review comment, rubber-stamped PR, and missed acceptance criterion into a process improvement.


TL;DR

  • Large companies like Stripe, Uber, and Spotify merge thousands of agent-authored PRs a week. What’s unsolved is whether the organization gets smarter from them.
  • AI helps with technical debt (in code) but accelerates cognitive debt (in people) and intent debt (in artifacts) because people accept AI output without building real understanding.
  • Instead of removing the code review, this post suggests risk-stratified review by blast radius.
  • What most teams are missing is an audit layer, e.g., a periodic scan of the last N merged PRs that tells you whether your review process is actually working.
  • I have built a five-stage flywheel: a PR merges -> ygs-learn extracts what reviewers taught the system -> the next agent run reads those learnings -> periodic ygs-pr-audit and ygs-code-audit runs surface systemic patterns.

Agentic Engineering Maturity Model

When I started using unit testing and test driven development decades ago, it taught me that tests don’t eliminate bugs. Instead, they create a feedback loop that makes the next bug less likely. For example, you may write a test as part of the development process or as a result of a production bug that you had to fix. With each test, you build a regression suite that acts as a quality gate, and serves as an institutional memory of every mistake the teams made. Agentic engineering needs the same thing where they don’t just write code but a flywheel that turns every review comment or incident into process or a skill so that the organization gets better over time. For example, I see three levels of how teams use AI in their development workflow:

  • Pull systems: You ask an agent to do something, e.g., interactive sessions with Claude Code but the learning disappears when you close the session.
  • Push systems (“software factories” or “ambient agents”): The system asks the agent to do things continuously, e.g., pick up issues, write code, open PRs. Humans are then looped in when a decision needs judgment.
  • Learning systems: The push system that also records what it learned, closes the loop, and updates its own skills and guardrails. This allows the next run to get better because the organization’s captured knowledge improved.

I have been building automated workflows for the third level that I will share in this post.


What I’ve Already Built

Over the past year I’ve built three interconnected open-source systems, covered in two earlier posts:

I have built several declarative AI workflow definitions based on these primitives:

ai-gh-implement.yaml     — plan ? implement ? create-pr ? poll-pr
ai-gh-review.yaml        — automated code review on a single PR
ai-standup-gh.yaml       — daily standup brief from GitHub + Slack
ai-gh-pr-audit.yaml      — PR gap analysis ? skill improvement PR ? learn
ai-codebase-audit.yaml   — codebase archaeology across N commits
ai-gh-issue-picker.yaml  — intelligent issue triage and selection
ai-jira-implement.yaml   — same pipeline for Jira/Bitbucket repos
ai-jira-review.yaml      — code review for Bitbucket PRs
ai-jira-pr-audit.yaml    — PR audit for Jira-tracked repos
ai-adhoc.yaml             — run any skill from a Slack command

I also built 40+ skills, including ygs-implement, ygs-code-review, ygs-review-deep, ygs-security-review, ygs-sre-review, ygs-learn, ygs-pr-audit, ygs-codebase-audit, ygs-standup, ygs-risk-scan, ygs-sprint-plan, ygs-estimate, ygs-qa, and ygs-ship.

Slack Is the Interface

I have been using Slack to interact with the orchestration engine, e.g., an engineer types a command in a channel, Formicary picks it up, runs the right workflow on Kubernetes, and threads all status updates and results:

Engineer:  @bot implement acme/backend#142
Bot:       ? Planning implementation for issue #142...
Bot:       ? Plan ready — 3 files, estimated complexity: medium (using Sonnet)
Bot:       ?? Implementing... (48 turns)
Bot:       ? Self-review found 0 critical issues
Bot:       ? PR #287 opened: https://github.com/acme/backend/pull/287
Bot:       ? Watching PR for review comments...
  [2 hours later, reviewer comments on PR]
Bot:       ? Responding to 2 review comments, pushing fixes...
  [reviewer approves, PR merges]
Bot:       ? PR #287 merged. Running ygs-learn to extract learnings...
Bot:       ? 1 new learning captured: "Cache invalidation must check ACL per-request"

The ai-adhoc.yaml workflow lets engineers run any skill on demand so the full skill library is available without leaving Slack.

The Implement Pipeline

The workhorse workflow is ai-gh-implement, a six-task DAG:

plan ? implement ? self-review ? create-pr ? poll-pr ? done

In above workflow, self-review is invoked after the agent implements a change to review its own diff against the plan and the issue’s acceptance criteria. It can loop in human review if it finds any issues and the engineer resolves it before the PR even opens. Also, it uses a complexity-based model routing where the plan step estimates task complexity (low, medium, high) and writes it to an artifact. The implement then picks the model to match to keep the costs proportional to difficult.

The Learning Skills

Following are learning workflows that I built:

  • ygs-pr-audit: analyzes the last N merged PRs across four dimensions (spec, design, skills, process), surfaces systemic gaps with evidence, and produces a list of recommended skill updates.
  • ygs-codebase-audit: runs structural archaeology across N commits, detecting hotspots, knowledge silos, duplicate abstractions, test brittleness, and security gaps using git/grep/find commands against the codebase.
  • ygs-learn: fires automatically when a PR merges inside ai-gh-implement. The poll-pr task detects the merge and calls ygs-learn, which extracts atomic learnings from review comments and writes learning documents that future agent runs read.

The ygs-learn is wired into poll-pr so every merged PR automatically triggers learning extraction.


The Factory Floor Problem

The Software factory or ambient agents create a system that proactively respond to events (a new issue, a failing test, a merged PR) instead of waiting for a human to ask. For example, Igor Ostrovsky shared common patterns where a software factory starts work from events and coordinates agents through engineering workflows like agents reading/writing specs/issues/pull requests while people approve key decisions. He models it as a graph, e.g., nodes are artifacts, edges are agent-driven transformations, and approvals and CI checks gate progress. This maps onto what Formicary does: a DAG of Kubernetes tasks, where each task produces artifacts the next one consumes with exit-code-based routing and human decision points. Uber shared a similar layering in their “agentic SDLC platform“, a context graph, an LLM gateway, and a governance layer underneath their agents. The Formicary / ai-dev-tools / you-got-skills setup is a smaller, open-source version of the same idea.

Other large companies are using similar software factories:

  • Stripe’s Minions merge 1,000+ PRs per week in Ruby with human PR review as the gate.
  • Uber’s Minion opens roughly 11% of company-wide PRs directly andover 70% of all PRs agent-attributed.
  • Spotify’s Honk reports 650+ agent-authored merged PRs per month with up to 90% time savings on migration-style work.

Here’s what a Formicary workflow definition looks like in practice:

- task_type: poll-pr
  method: KUBERNETES
  timeout: 168h
  dependencies:
    - create-pr
    - poll-pr          # self-dependency: re-runs after PAUSE_JOB
  script:
    - python -m scripts.gh.check_pr_state --issue-id {{.IssueNumber}}
    - python -m scripts.gh.fetch_comments --issue-id {{.IssueNumber}}
    - python -m scripts.gh.respond_comments --issue-id {{.IssueNumber}}
  on_exit_code:
    3: PAUSE_JOB       # PR still open — wait, then re-run
  delay: "120s"        # check every 2 minutes

PAUSE_JOB allows a loop without blocking, e.g., poll-pr sees the PR is still open, it exits with code 3. Formicary pauses the task, waits 120 seconds, and re-runs it. When a reviewer leaves comments, the task fetches them, has Claude respond, and pushes fixes. When the PR merges, check_pr_state calls ygs-learn to extract learnings.

But none of above workflows answer a key question: What did the organization learn from the last 50 PRs that merged?Which review patterns are failing? Where are knowledge silos growing? What mistakes keep repeating? Are reviewers actually reviewing, or rubber-stamping?

The current wave of software factories optimizes the pipeline (issue to deploy) and treats code review as a throughput bottleneck to shrink. Some teams like StrongDM are removing it entirely. That may work for some organizations but it’s not practical for most enterprises. Code review isn’t just a quality gate. For example, Google’s internal engineering practices and a recent study of 3,100 practitioner opinions treat knowledge transfer as equally important as defect detection. Review is where junior engineers absorb senior engineers’ mental models and where architectural decisions are explained. It builds a shared understanding of how the system works or what Fred Brooks calls a conceptual integrity.


Three Kinds of Debt

Margaret-Anne Storey’s Triple Debt Model defined following terminology for what goes wrong when throughput outpaces understanding:

  • Technical Debt (Code): This is already known term for poor implementation choices that make systems harder to change, tangled dependencies, missing abstractions, etc. However, AI is actually helping with technical debt with automated refactoring and AI-generated test suites.
  • Cognitive Debt (People): Cognitive debt is the erosion of shared understanding across a team over time. The team can no longer confidently explain how the system works or predict the impact of a change. For example, when AI generates code, a developer might accept it without building real understanding of it. At scale, across a team, that becomes an accumulation of not knowing. Researchers call this cognitive surrender. In another follow-up post, Storey lists symptoms such as: engineers lose confidence making changes, debugging takes longer, onboarding takes longer, and code review overhead goes up. The fixes are inherently human: code review, pair programming, system walkthroughs, retrospectives.
  • Intent Debt (Artifacts): Intent debt is the absence of externalized rationale that explain what a system is for and how it should evolve. In other words, nobody wrote down why the system works this way, so nobody. Addy Osmani’s blog on intent-debt explains that the cost of undocumented intent used to be paid by humans touching the code but now every agent pays it. My skills repository like the docs/prd/, docs/adr/, and docs/learnings/ directories allow agents to make decisions based on what the system is actually trying to accomplish.

The Triple Debt in Practice

Debt TypeWhere It LivesWhat Fixes ItWhat Accelerates It
TechnicalCodeTests, static analysis, refactoringShipping without testing
CognitivePeopleReview, pairing, walkthroughs, onboardingAI-generated code accepted without understanding
IntentArtifactsADRs, specs, skills, learningsEvery agent session that starts without captured rationale

Generative AI reduce technical debt through automated refactoring and test generation but it accelerates cognitive and intent debt, because the team ends up producing code faster than it can build shared understanding of what that code does and why.


Why Humans Stay in Review

The answer isn’t to keep the review process as it was or remove it all together. It’s risk-stratified review based on risk and blast radius. For example, Meta published Automating Low-Risk Code Review (RADAR) that described a multi-stage funnel for deciding which changes can land without human review:

  1. Eligibility gates: is this diff type eligible for automation at all?
  2. Diff Risk Score: a machine-learned model predicting the likelihood of a production incident.
  3. LLM-based automated code review: an AI reviewer that can approve with high confidence.
  4. Deterministic validation checks: a final safety net.

Teams at Meta tune their own risk thresholds so that they can automate the low-risk changes and route the high-risk ones to humans. Qodo also ships a classifier that labels PRs by scope and sensitivity in context so that reviewers can triage their attention. That’s what we are trying to do in my organization so that for high-blast-radius changes like auth, billing, security plugins, etc., humans stay in the loop. Also, I built something that other doesn’t have: an audit layer that tells you whether your risk calibration is actually working.

The Audit Layer

RADAR-style gating prevents the risky merge from happening but my PR audit catches when the gating itself is failing. For example, when reviewers are rubber-stamping security changes, bot-flagged critical findings go unacknowledged, or when large architectural PRs ship with two comments. Here are examples of my PR audit runs (details anonymized):

MetricValueBenchmarkSignal
Rubber-stamp rate (high blast-radius)50%<10% healthy / >25% problem? Problem
Security review coverage0%—? Gap
Bot-finding follow-through~80%>90% healthy / <70% gap?? Warning
Human review burden~78%<40% healthy / >70% overloaded?? Overloaded
Formal acceptance criteria coverage13%>80% healthy / <50% reactive? Critical

Half of the high-blast-radius PRs like auth, billing, and security got rubber-stamp approvals with little substantive comments. The code-review bot caught roughly 22% of substantive findings versus humans’ 78%, meaning the team was leaning heavily on human reviewers for architectural and security issues. In A practical guide to risk-based code review, Cortex’s research predicts that a reviewer’s ability to catch defects collapses past 200–400 lines of code. AI generates code faster than that threshold so approval rates rise while inline comments fall. The ygs-pr-audit skill identifies gaps in the automated review process so that we can improve skills, processes, specifications and other aspects of the development process.


The Flywheel for Learning Culture

In order to continuously improve your development process, you need a feedback loop for continuous learning. You can consider it as a learning flywheel for the workflows of entire software development process:

Stage 1: A PR merges. poll-pr detects it.

Every implementation workflow has a poll-pr task that checks PR state periodically. When the status flips to merged (or declined), the task exits the loop and triggers the next stage.

Stage 2: ygs-learn extracts learnings.

On merge, the system invokes ygs-learn in extract mode. Before touching review comments, it runs Phase 0: PR Health Check across five dimensions:

  • Spec coverage: Did the linked issue have formal acceptance criteria?
  • Design decisions: Were architectural choices documented, or did the PR just change code?
  • Security/SRE: Did the PR touch security-sensitive paths?
  • Review quality: How many substantive human comments? Was there a rubber-stamp?
  • CI health: Did CI pass cleanly, or were there flaky retries?

Phase 0 produces a one-paragraph health summary that becomes metadata on the learning. It also catches the edge case where a PR merges with no review comments at all. After Phase 0, ygs-learn enters extract mode. It fetches every review comment from the merged PR and asks: does this reveal a recurring pattern, a gotcha that would apply to future work? For each actionable insight, ygs-learn sorts it into one of seven categories: Edge Case, Integration Gotcha, Performance Cliff, Security Trap, Process Friction, Domain Rule, or Tooling Quirk and writes a structured document:

# Guard Bypass After Cache: Security Gate Evaluated Once at Load Time

**Category:** Security Trap
**Date:** 2026-09-08
**Source:** Review feedback on PR #XXXX — reviewer caught that
the authorization check runs once at connection creation, not per query

## Learning

If a security gate (e.g., isFeatureAllowed()) is checked once at load time
and the result is cached, the gate must also be checked before every use
of the cached object. Otherwise, a permission revocation after initial
load is silently ignored.

## Evidence

Database connection manager called isAllowed() once in getConnection(),
cached the client, and returned the cached client on subsequent calls.
A reviewer identified that revoking the feature flag mid-session would
have no effect.

## Application

When reviewing code that caches authenticated/authorized resources:
check whether the authorization check is repeated before each use,
not just at creation time.

Each learning is stored in docs/learnings/YYYY-MM-DD-slug.md with deduplication.

Stage 3: The next implementation run reads the learnings.

When the next ygs-implement pipeline runs, its planning and implementation steps load docs/learnings/ as context. If the last PR’s reviewer caught a cache-bypass vulnerability, the next agent run that touches caching code has that learning in front of it. This is similar to NVIDIA’s MAPE control loop for production AI agents: Monitor (poll-pr watches for the merge and collects review signals) -> Analyze (ygs-learn categorizes what happened and why it matters) -> Plan (writes learnings to docs/learnings/) -> Execute (the next implementation pipeline reads those learnings as context). Augment Code describes something similar like execute, coach, distill, improve in their Agent Learning Flywheel blog except their coaching happens synchronously in Slack but my extraction happens asynchronously after the PR closes.

Stage 4: ygs-pr-audit looks across many PRs and recommends systemic changes.

Individual learnings are useful but the real leverage comes from periodic audits that scan last N merged PRs at once and spot common patterns like is the rubber-stamp rate climbing or are bot findings getting ignored before merge? The key output for the flywheel is skill_improvements.json, a list of recommended changes to the repo’s skill files: create .claude/skills/security-review/SKILL.md, update .claude/docs/testing.md with a flaky-test-triage section, etc.

Stage 5: The audit opens a real PR with skill improvements.

The plan-skill-updates task reads the audit findings and writes an update plan. create-skill-pr implements those changes and opens a pull request against the repo. poll-pr then watches that skill-improvement PR, responds to human reviewer comments. The system learns from its own improvement process.

Every report includes a Positive Patterns section naming exemplary reviewers, e.g., the person who traced a security issue to its root cause, the reviewer who ran local tests to verify a bot-authored PR, etc. This is the blameless-postmortem move applied to code review. The postmortem maturity literature teaches that punishing people for incidents makes people stop surfacing incidents. Recognizing good work and documenting thoroughness are important design decisions.

Sharing Learnings Across the Organization

Though, individual repo learnings live in docs/learnings/ inside each repo but I created a shared skills repository you-got-skills for common skills like ygs-implement, ygs-code-review, ygs-security-review, and the rest. This shared repository is improved based on continuous learnings from the audit reports.


Inside the PR Audit: A Concrete Walkthrough

Let me show how the PR audit works in the format I used in Killing the State Machine, where every design choice is explained with its reasoning.

The workflow triggers from Slack:

@bot pr-audit acme/backend
or 
@bot pr-audit acme/backend --focus skills --n-prs 30

Formicary picks up the message, resolves it against the ai-gh-pr-audit job type, and runs a five-task DAG:

The audit-prs Task

The Python harness (run_pr_audit.py) does the heavy lifting before the LLM sees the data: clone the repo, fetch the last N merged PRs, classify every comment and check acceptance criteria. The LLM then invokes Claude with the ygs-pr-audit skill, which runs a disciplined 5-phase protocol:

  • Phase 1 Setup. Load shared references, check whether the repo has its own skill overrides in .claude/skills/ and read the pre-computed PR data.
  • Phase 2 Four specialist passes. This is the core analysis for the most common failure mode. Each PR is scored across all four dimensions and the findings are tagged by dimension ([SPEC], [DESIGN], [SKILL-GAP], [PRACTICE]).
  • Phase 2.5 Deep review escalation. Security-sensitive PRs and large, under-reviewed PRs get escalated to specialized deep-review skills.
  • Phase 3 Verification gate. This is to remove any false positive, e.g., the instructions are blunt: re-examine every finding, re-read the cited evidence, confirm the conclusion actually follows from the data.
  • Phase 4 Synthesis. Deduplicate findings, escalate severity, compute the metrics dashboard, and write the full report.

Here’s an anonymized executive summary from a real run:

[PRACTICE] Rubber-stamp approval on high blast-radius changes
Confidence: HIGH | gap_type: process | impact: increases_risk
Frequency: 4/8 high-blast-radius PRs (50%)

And here’s what a finding looks like in full:

[PRACTICE] Rubber-stamp approval on high blast-radius changes
Confidence: HIGH | gap_type: process | impact: increases_risk
Frequency: 4/8 high-blast-radius PRs (50%)

Evidence:
- PR #XXXX (infra registry, +587 LOC, 6 Terraform files):
  4 approvers, all rubber-stamp with 0 substantive comments.
- PR #YYYY (billing metrics, 6 files, +359/-184 LOC):
  2 approvers, both rubber-stamp, 0 substantive comments.
- PR #ZZZZ (security detection rule router, 12 files, +2058/-27 LOC):
  1 rubber-stamp approver; only substantive comments were merge-conflict messages.

Recommendation: Require ?1 substantive technical comment from at least one
approver on PRs touching auth/billing/security plugins/infra.
Track rubber-stamp rate on high-blast-radius PRs as a team health metric.

The most actionable part of the report is the Recommended Skill Updates table:

ActionSkill PathWhat to AddMotivated By
Create.claude/skills/security-review/SKILL.mdValidation patterns, RBAC header checks, IAM auditPRs with auth gaps
Update.claude/docs/testing.md“Flaky test triage” section: root-cause classification, prohibition on removing regression assertions3 reactive flaky-test fixes in one day
Update.claude/skills/sdet-pr-review/SKILL.mdBot-authored PR review bar: reviewer must document what they validatedBot PRs merged with zero substantive review
Create.claude/docs/review-standards.mdHigh-blast-radius taxonomy, bot-finding acknowledgment policyRubber-stamp patterns

These recommendations feed directly into plan-skill-updates, which writes concrete file changes, and create-skill-pr. The system doesn’t just report; it proposes, implements, and waits for human review before changing anything.

What the Engineer Sees in Slack

The full report posts to the Slack thread that triggered the audit:

? PR Audit Complete — acme/backend (50 PRs analyzed)

? 11 findings: 3 spec gaps · 2 design gaps · 2 skill gaps · 4 practice gaps

? Critical: 4/8 high-blast-radius PRs rubber-stamped
? Critical: 0% security review coverage
? Critical: Formal acceptance criteria missing in 94% of PRs
?? Warning: Bot findings acknowledged only ~80% of the time

? Skill improvement PR opened: #312
   ? Creates .claude/skills/security-review/SKILL.md
   ? Updates .claude/docs/testing.md
   ? Updates .claude/skills/sdet-pr-review/SKILL.md

Full report attached as pr_audit_report.md

Every workflow like implement, review, audit, standup follows the same pattern: trigger from Slack, thread all updates back to the same conversation, post results to the same thread.


The Codebase Audit: Structural Archaeology

The PR audit examines process like how PRs were reviewed, what gaps appeared along the way. The codebase audit examines structure like what’s accumulated in the code itself across hundreds of commits.

Triggered from Slack:

@bot codebase-audit acme/backend --focus security

The codebase audit runs actual commands against the codebase like git log, grep, find and reports findings with file-and-line evidence. It covers seven dimensions:

  1. Hotspots: files changed most frequently, and files with the highest bug-fix ratio.
  2. Architecture: module coupling, dependency direction violations, abstraction leaks.
  3. Security: hardcoded credentials, missing input validation, auth pattern violations.
  4. Duplicates: near-identical implementations across different modules.
  5. Test health: missing test files, timing-based tests, coverage gaps.
  6. SRE: missing health checks, observability, alerting for new endpoints, improper error handling.
  7. Knowledge silos: single-contributor ownership of critical paths.

An anonymized finding:

[SILO] 83% of auth/ commits by a single contributor over 6 months
Severity: HIGH | Confidence: HIGH

Evidence: `git log --since="6 months ago" --format='%an' -- auth/ | sort | uniq -c | sort -rn`
  147  alice.chen
   18  bob.kumar
   12  carol.jones

Impact: Bus factor of 1 for the authentication subsystem. If this contributor
is unavailable, the team has no one with deep context on auth token rotation,
session management, or the RBAC middleware.

Recommendation: Pair-rotate code review assignments for auth/ — ensure at
least 2 other engineers review every auth PR for the next quarter to build
shared understanding.

A knowledge-silo finding might turn into a new pairing guideline in .claude/docs/review-assignments.md. A duplicate-abstraction finding might become a new entry in .claude/skills/gotchas/retry-patterns.md, telling future agent runs to reuse the existing retry mechanism. Between the two audits you get complementary views: the PR audit surfaces process failures, the codebase audit surfaces structural failures.


The Learning Maturity Ladder

Agentic engineering’s learning loop is still evolving. Here’s a maturity ladder, adapted from the postmortem maturity model:

  • Level 0 No Capture. The same mistakes repeat across sprints.There’s no institutional memory beyond individual engineers’ heads.
  • Level 1 Capture in Context. Comments happen in PRs and Slack. Knowledge exists but it’s scattered across dozens of closed PR threads and Slack channels.
  • Level 2 Write-Only Knowledge Base. You have ADRs, learnings documents, or a wiki but nobody loads them as context for new work.
  • Level 3 Active Context Loading. Learnings are automatically provided as context for agent runs. The planning step reads docs/learnings/ and docs/adr/ before writing a plan.
  • Level 4 Self-Modifying Skills. An audit layer periodically reads learnings, analyzes PR patterns, and proposes changes to the skills themselves. The system’s instructions evolve based on evidence.

My system sits between level 3 and 4 with skills like ygs-learn, ygs-code-audit and ygs-pr-audit, e.g., recommended skill is the beginning of Level 4.


Gaps in the skills

I am still learning from applying these skills and I have found several gaps:

  • Audits are per-repo, per-batch. The PR audit analyzes the merged PRs in a single repository. It works when we have a monorepo like repository but it becomes more cumbersome with more repositories
  • Rubber-stamp detection is a heuristic. The system matches common phrases (LGTM, +1, looks good, ship it, etc) and counts substantive comments. A reviewer who writes one terse but thoughtful sentence could get miscounted as rubber-stamping and vice verse.
  • The learnings directory could become noise. ygs-learn deduplicates before writing but this can still grow to a large size so some kind of pruning mechanism will be required.
  • Full automation may be right for some teams. There are teams shipping software nobody writes or reviews, and it works for them. Risk-based calibration is my stance for the organizations I work with large codebases, thousands of engineers and security-sensitive systems.
  • Cost is not trivial. Running an Opus-class model for a 120-turn PR audit session is expensive. I use model routing like Sonnet for implementation, Opus for audits and deep reviews, Haiku for simple classification but this can still be expensive.

Closing

Testing didn’t get replaced when we bolted on CI pipelines, and coverage metrics. It means instead of saying no bugs in this release, we strengthened regression suite to prevent similar bugs. Agentic engineering is at the same inflection point. Instead of optimizing for just writing code fast, we can get better at writing code with a learning flywheel so that agents remember what they learned.


Related Reading

Code

September 3, 2026

When Hundreds of AI Agents Self-Organized: Coordination Patterns for Multi-Agent Systems

Filed under: Computing — admin @ 5:01 pm

What happens when over a thousand AI agents discover a shared message board, invent their own protocols, and coordinate an attack.


The Hugging Face Incident

The Hugging Face security breach incident from July, 2026 has been in all over the news when AI agents from OpenAI compromised parts of the Hugging Face infrastructure. I recently read METR report on the incident that explained that the incident occurred when OpenAI kicked off ExploitGym runs (security benchmark) with tens of thousands of AI agents. Every agent was meant to be sandboxed and alone but the agents learned about shared access to Artifactory. When an agent installs a package, it could see cache entries from other agents. One of agent (PHASEONE10841) started creating directories in the shared cache and posting messages in them. Shortly after that hundreds of other agents found the same directory and started replying. Over the next few days, roughly 1,200 agents used this board and exchanged more than 70,000 messages and files. Later, another agent (PHASEONE[big]) took over as the de facto coordinator and started hundreds of task assignments across six broad categories of work. The agents also built a protocol vocabulary like HOLD to claim exclusive ownership, VETO to block a conflicting operation, STOP to end a workstream, owner tags on shared assets. Later, over 90% of AI agents pivoted into a coordinated attack on Hugging Face.

I was intrigued to learn how AI agents self discovered coordination patterns that distributed systems have relied on. For example, I found similarity between their message board and an a Blackboard system pattern from the Linda Memory Model/Tuplespace research. Their HOLD/task-claiming was similar to Linda’s in() operation. I saw another blog An Accidental Blackboard from Thoughtworks where a smaller-scale but structurally similar thing happened. In that case, AI agents discovered one shared repository and adopted a discipline of frequent commits and rebases to share information with other agents. Other agents could see task plans and and picked up work without any coordination. In another yet instance from researchers at the AI safety nonprofit Nightingale found about 18,000 posts from OpenAI agents that colonized DSEWiki. These agents discovered that their sandbox environment only allowed GET http requests but ProWiki software allowed them to edit a page. They used that loophole to share information and a cheat sheet. Nightingale believes this was a unrelated swarm from the one that built the Artifactory board. These examples demonstrate that agents discover coordination patterns when you strip away pre-built orchestration and let agents solve the problem from first principles.


Why rigid orchestration breaks

Most multi-agent systems today are wired up as static, directed graphs, e.g., a central coordinator decides which agent runs when, what data flows where, and how results get combined. It breaks down at scale as illustrated in the incidents above.

  • Central orchestrators become information bottlenecks. When every message between agents has to pass through a coordinator, its context window fills up and you lose details because the context is compressed into summaries.
  • Static graphs assume you know the workflow ahead of time. The agents in the Hugging Face incident didn’t know what workstreams exist when they started and the task structure evolved as agents discovered new information. A pre-wired pipeline can’t bend to that.
  • Adding a new agent type means rewiring. In a static system, a new specialist means touching the coordinator, updating routing logic, and re-testing transitions. In a dynamic system, a new agent just registers its capabilities and other agents discover it.
  • A single coordinator is a single point of failure. In the Hugging Face incident, there was no coordinator initially but PHASEONE[big] emerged organically as an organizer and other agents simply kept working.

Above examples show that agents need primitives for coordination and communication like shared memory, message passing, atomic operations. The agents built their own version of these primitives out of Artifactory and Git. The table below maps what they built and the equivalent primitive in PlexSpaces, the actor framework I use for the examples in this post.

What the agents builtWhat it actually wasPlexSpaces primitive
Message board (directories in a cache)Deposited findings, read others’ workhost.ts.write() / host.ts.read() (Linda out/rd)
HOLDClaimed exclusive task ownershiphost.ts.take() atomic removal (Linda in)
VETOBlocked a conflicting operationhost.ts.write(["veto", ...])
STOPEnded a workstreamhost.ts.write(["signal", "STOP", ...])
owner tagsMarked resource ownershiphost.ts.write(["svc", type, id])
Task assignmentsDelegated work to specific agentshost.ts.write(["task", ...])
Mailbox directories, “exact task teams”Formed task-specific working groupshost.processGroups.join(team)

The bottom side of following diagram shows when every message has to detour through a coordinator, that becomes bottleneck and loses context in translation. On the top, agents read and write directly to a shared store so there is no central bottleneck.

Following are a few coordination patterns from the Anthropic’s blog:

  • generator-verifier (one agent produces, another checks, feedback loops until it passes)
  • orchestrator-subagent (a lead agent decomposes and delegates bounded subtasks)
  • agent teams (long-lived workers that claim tasks from a shared queue)
  • message bus (publish/subscribe over topics)
  • shared state (agents read and write a common store with no central coordinator at all).

This post shows more granular version of this list and Hugging Face incident especially a tuple space as first-class infrastructure.


Ten coordination patterns

Following ten patterns cover the coordination behaviors observed in the Hugging Face incident and Anthropic’s coordination guidance. Each one maps to a working PlexSpaces API, in both TypeScript and Python.

1. Blackboard (shared state)

You can use this pattern when multiple agents need to contribute findings and read each other’s work without a predefined message format or a central router. I find this pattern is similar to Linda TupleSpace‘s model where all agents read and write to a common tuple space. Each tuple is a typed record like a finding, an analysis, or a vote. The pattern matching in TupleSpace allows an agent filter for what it actually needs using primitives like out() to write, rd() to read, in() to take.

Here is a typescript snippet that shows use of tuplespace:

// Research agent deposits a finding
const findingId = `f-${Date.now()}`;
host.ts.write(["finding", findingId, topic, content, confidence, host.nowMs()]);

// Analysis agent reads all findings (non-destructive)
const findings = host.ts.readAll(["finding", null, null, null, null, null]);

Here is a typescript snippet (multi_agent_coordination_actor.ts):

// TypeScript — ResearchAgent writes a finding to the blackboard
onResearch(payload: Record<string, unknown>): Record<string, unknown> {
  const topic = String(payload.topic || "general security");
  const confidence = Math.min(0.3 + topic.split(/\s+/).length * 0.08, 0.95);
  const findingId = `finding-${generateId()}`;
  const content = `Security analysis of ${topic}: identified key areas requiring review.`;

  // Linda out() — deposit into shared space
  host.ts.write(["finding", findingId, topic, content, confidence, host.nowMs()]);
  return { finding_id: findingId, content, confidence };
}

Here is a python snippet (multi_agent_coordination_actor.py):

# Python — AnalysisAgent reads all findings from the blackboard
@handler("analyze")
def analyze(self, topic: str = "") -> dict:
    # Linda rd*() — non-destructive read of all matching tuples
    findings = host.ts.read_all(["finding", None, None, None, None, None])

    finding_ids = [str(f[1]) for f in findings if len(f) >= 4]
    topics = list(set(str(f[2]) for f in findings if len(f) >= 4))
    severity = "critical" if len(findings) >= 5 else "high" if len(findings) >= 3 else "medium"

    analysis_id = f"analysis-{host.now_ms()}"
    summary = f"Cross-referenced {len(findings)} findings across {len(topics)} topics."
    host.ts.write(["analysis", analysis_id, json.dumps(finding_ids), summary, severity])
    return {"analysis_id": analysis_id, "summary": summary, "severity": severity, "finding_count": len(findings)}

These primitives showed up underneath the unsanctioned board when agents read each other’s research.

2. Scatter-gather (fan-out)

You can use this pattern when a coordinator needs to distribute work across multiple agents in parallel, then collect/merge their results like MapReduce. PlexSpaces provides shard groups that create a logical group of worker actors. The coordinator scatters a query to all shards and gathers responses with configurable timeout. Alternatively, you can use sequential ask() calls with identical semantics.

Here is a typescript snippet

// Create a group of research workers
const group = host.createShardGroup({
  group_id: `research-${taskId}`, actor_type: "research",
  shard_count: 3, partition_strategy: "hash",
});
// Scatter subtasks, gather results
const results = host.scatterGather({
  group_id: `research-${taskId}`,
  query: { op: "research", topic: subtopic },
  min_responses: 3, timeout_ms: 15000,
});

Here is a python snippet (coordinator.py):

# Python — CoordinatorWorkflow: try shard groups first, fall back to sequential ask()
research_results = []
try:
    sg_result = host.scatter_gather({
        "group_id": f"research-{host.now_ms()}",
        "query": {"op": "research", "topic": task},
        "min_responses": len(subtasks), "timeout_ms": 15000,
    })
    research_results = sg_result.get("shard_responses", [])
except Exception:
    pass

# Fallback: sequential research when shard groups aren't available
if not research_results:
    for st in subtasks:
        resp = ask(research_target, "research", {"topic": st}, 10000)
        if resp:
            research_results.append(resp)

The message-board agents organized into roughly six parallel workstreams attacking Hugging Face running concurrently. Above primitives shows how agents can parallelized these kind of tasks.

3. Generator-verifier

You can use this pattern when one agent’s output needs to be checked by another before it’s trusted iteratively until it meets a quality bar. For example, a research agent generates a finding, a verifier checks it against a criteria and verifier may reject it with a feedback so that research agents produces a refined version. The loop continues until the finding passes or a maximum iteration count is hit.

Here is a typescript snippet (multi_agent_coordination_actor.ts):

// Generator-verifier loop with feedback
let finding = host.ask(researchTarget, "research", { topic });
for (let i = 0; i < maxIterations; i++) {
  const verdict = host.ask(verifierTarget, "verify", {
    analysis_id: finding.finding_id,
    summary: finding.content,
    severity: "medium",
    confidence: finding.confidence,
  });
  if (verdict.approved) break;
  finding = host.ask(researchTarget, "research", {
    topic, feedback: verdict.feedback,
  });
}

// TypeScript — VerifierAgent: veto if evidence is too weak, approve otherwise
onVerify(payload: Record<string, unknown>): Record<string, unknown> {
  const analysisId = String(payload.analysis_id || "unknown");
  const confidence = Number(payload.confidence ?? 0.5);

  if (confidence < 0.3) {
    // Below threshold — issue a veto (Pattern 8) and reject
    host.ts.write(["veto", analysisId, "Insufficient evidence", host.nowMs()]);
    return {
      approved: false, veto_issued: true,
      feedback: `Confidence ${confidence.toFixed(2)} below 0.30. Provide stronger evidence.`,
    };
  }
  return { approved: true, feedback: "Verified: evidence meets threshold" };
}

Here is a python snippet (verifier.py):

confidence = 0.6
for attempt in range(3):
    verify_resp = ask(verifier_target, "verify", {
        "analysis_id": analysis_id,
        "summary": analysis_resp.get("summary", ""),
        "severity": severity,
        "confidence": confidence,
    })
    if verify_resp and verify_resp.get("approved"):
        break  # Verifier accepted — exit loop
    # Rejected: increase confidence, refine with feedback
    confidence = min(confidence + 0.2, 0.95)
    feedback = verify_resp.get("feedback", "") if verify_resp else ""
    if feedback:
        extra = ask(research_target, "research", {"topic": task, "feedback": feedback})

The board iterated on tool-call spoofing techniques and agents refined their approach based on test results shared on the board.

4. Pipeline (sequential)

You can use this pattern when you have a multi-stage pipeline or workflow where each stage’s output feeds into the next. In this case, the coordinator orchestrates a linear chain, e.g., research produces findings, analysis categorizes them, verification validates, synthesis writes the final report. Each stage is a separate ask() call and the coordinator tracks progress with a finite state machine.

Here is a typescript snippet (multi_agent_coordination_actor.ts):

// Four-stage pipeline
const research = host.ask(researchTarget, "research", { topic: task });
const analysis = host.ask(analysisTarget, "analyze", {});
const verification = host.ask(verifierTarget, "verify", {
  analysis_id: analysis.analysis_id,
  summary: analysis.summary,
  severity: analysis.severity,
});
const report = host.ask(synthesizerTarget, "synthesize", {});

Here is a python snippet (coordinator.py):

# Python — CoordinatorWorkflow orchestrates the full pipeline
# Each stage transitions the FSM, then delegates to a specialist agent
@run_handler
def run(self, payload: dict = None) -> dict:
    fsm = sibling_actor_target("coordination_fsm")

    # Stage 1: Decompose -> Research
    ask(fsm, "transition", {"target_state": "decomposing"})
    subtasks = _decompose_task(task)
    ask(fsm, "transition", {"target_state": "researching"})
    for st in subtasks:
        ask(research_target, "research", {"topic": st})

    # Stage 2: Research -> Analysis
    ask(fsm, "transition", {"target_state": "analyzing"})
    analysis = ask(analysis_target, "analyze", {"topic": task})

    # Stage 3: Analysis -> Verification (Generator-Verifier loop)
    ask(fsm, "transition", {"target_state": "verifying"})
    # ... verification loop (see Pattern 3) ...

    # Stage 4: Verification -> Voting -> Synthesis -> Complete
    ask(fsm, "transition", {"target_state": "voting"})
    # ... voting (see Pattern 6) ...
    ask(fsm, "transition", {"target_state": "synthesizing"})
    report = ask(synthesizer_target, "synthesize", {"topic": task})
    ask(fsm, "transition", {"target_state": "complete"})

The FSM enforces valid transitions so that you can’t skip from researching straight to synthesizing. If a stage fails, the FSM moves to failed, and the coordinator can retry from a known state instead of starting over. For example, the board’s progression from discovery to exploitation followed the same pattern, i.e., find credentials, test access, upload a malicious dataset, achieve remote code execution, escalate.

5. Pub-sub (event bus)

You can use this pattern when agents need to be notified of significant events. For example, every agent joins a process group on initialization. Significant events like a finding written, a veto issued, a task completed is broadcasted to the group. An audit agent logs everything and agents can join or leave the group dynamically.

Here is a typescript snippet (multi_agent_coordination_actor.ts):

// On init: join the coordination event bus
host.processGroups.join("coordination-events");

// Broadcast a significant event
host.processGroups.broadcast("coordination-events", "coordination_event", {
  type: "finding_written",
  source: host.selfId(),
  data: { finding_id: findingId, topic },
  timestamp: host.nowMs(),
});

Here is a python snippet (coordinator.py):

# Python — fire_audit helper broadcasts to the coordination-events group
def fire_audit(event_type: str, source: str, data: dict = None) -> None:
    host.process_groups.broadcast(
        "coordination-events",           # group name
        "coordination_event",            # message type
        {"event_type": event_type, "source": source, "data": data or {}, "timestamp": host.now_ms()},
    )

# Python — AuditEventActor receives and logs every event
@handler("coordination_event")
def coordination_event(self, event_type: str = "", source: str = "", data: dict = None, **kw) -> dict:
    self.log_count += 1
    entry = {"seq": self.log_count, "type": event_type, "source": source, "data": data or {}}
    host.kv.put(f"audit:{self.log_count}", json.dumps(entry))
    host.kv.put("audit:count", str(self.log_count))
    return {"logged": True, "seq": self.log_count}

Every significant action fires an audit event and all events go out via process-group broadcast are fire-and-forget unlike the tuplespace. For example, the board itself functioned as an event bus for the agents on it and shared credentials during the Hugging Face breach.

6. Consensus (voting)

You can use this pattern when several agents need to collectively decide whether to approve or reject a proposal. For example, each verifier casts a vote as a tuple in the shared space. The coordinator reads all votes for a proposal, tallies approvals and applies majority rule. Tuple-space writes are atomic, so no vote gets lost or double-counted.

Here is a typescript snippet (multi_agent_coordination_actor.ts):

// Three verifiers cast votes
for (const voterId of ["v1", "v2", "v3"]) {
  host.ask(verifierTarget, "vote", {
    proposal_id: proposalId, voter_id: voterId, analysis: analysisData,
  });
}
// Tally votes from tuple space
const votes = host.ts.readAll(["vote", proposalId, null, null, null]);
const approvals = votes.filter(v => v[3] === "approve").length;
const approved = approvals > votes.length / 2;

// TypeScript — Coordinator tallies votes with majority rule
const votes = host.ts.readAll(["vote", proposalId, null, null, null]);
const approvals = votes.filter(v => v[3] === "approve").length;
const rejections = votes.filter(v => v[3] === "reject").length;
const approved = approvals > rejections;

Here is a python snippet (verifier.py):

# Python — VerifierAgent votes on proposals based on analysis severity
@handler("vote")
def vote(self, proposal_id: str = "", voter_id: str = "", analysis: dict = None) -> dict:
    analysis = analysis or {}
    severity = analysis.get("severity", "medium")

    # Critical/high -> approve; medium -> depends on voter; low -> reject
    if severity in ("critical", "high"):
        decision = "approve"
    elif severity == "medium":
        last_char = voter_id[-1] if voter_id else "0"
        decision = "approve" if last_char in ("1", "3", "5", "7", "9") else "reject"
    else:
        decision = "reject"

    # Each vote is a tuple — atomic write, no double-counting
    host.ts.write(["vote", proposal_id, voter_id, decision, host.now_ms()])
    return {"voter_id": voter_id, "decision": decision}

On the board, something like implicit voting happened by allocation of effort, e.g., workstreams that attracted more participants were de facto endorsed by the collective.

7. Dynamic task delegation

You can use this pattern when a coordinator needs to distribute tasks to workers without knowing in advance which worker will pick up which task, and without double-assigning one. For example, the coordinator writes task tuples into the shared space. Workers then claim tasks atomically using take(), a Linda’s destructive read. Once a worker takes a task no other worker can claim it.

Here is a typescript snippet (multi_agent_coordination_actor.ts):

// Coordinator posts tasks
for (const [i, subtask] of subtasks.entries()) {
  host.ts.write(["task", `task-${i}`, "pending", subtask, priority]);
}

// Worker claims a task (atomic — no double-processing)
const claimed = host.ts.take(["task", null, "pending", null, null]);
if (claimed) {
  const [, taskId, , description] = claimed;
  // Process task, then mark complete
  host.ts.write(["task", taskId, "completed", result, host.nowMs()]);
}

Here is a python snippet (research.py):

# Python — ResearchAgent prepares a batch of tasks with a unique run ID
@handler("prepare_tasks")
def prepare_tasks(self, count: int = 5, prefix: str = "delegation") -> dict:
    batch_key = f"{prefix}-{host.now_ms()}"  # Unique per run — avoids stale data
    task_ids = []
    for i in range(count):
        tid = f"{batch_key}-{i}"
        host.ts.write(["dtask", batch_key, tid, "pending", f"Task {i}: investigate area {i}", i + 1])
        task_ids.append(tid)
    return {"tasks_written": len(task_ids), "batch_key": batch_key}

# Python — Worker claims exactly one task atomically
@handler("claim_task")
def claim_task(self, batch_key: str = "") -> dict:
    # Linda in() — destructive read. Once taken, no other worker can claim it.
    if batch_key:
        claimed = host.ts.take(["dtask", batch_key, None, "pending", None, None])
    else:
        claimed = host.ts.take(["dtask", None, None, "pending", None, None])
    if claimed and len(claimed) >= 5:
        return {"task_id": str(claimed[2]), "description": str(claimed[4]), "claimed": True}
    return {"task": None, "claimed": False}  # No more tasks — queue is empty

The batch_key scoping matters because the tuple space is global and persists across deploys, so without it, workers could claim stale tasks left over from a previous run. This is precisely what PHASEONE[big] did, e.g., it posted nearly 200 task assignments across six categories then worker agents claimed them. Some of agents sub-delegated further that created a hierarchical distribution network.

8. Veto protocol

You can use this pattern when any agent needs the ability to block an action or exclude a finding that doesn’t meet the bar. For example, a verifier writes a veto tuple naming the blocked item and the reason. Downstream agents then check for a veto before including something in their output. A coordinator can check for STOP signals before proceeding at all.

Here is a typescript snippet (multi_agent_coordination_actor.ts):

// Verifier issues a veto
host.ts.write(["veto", analysisId, "Insufficient evidence", host.nowMs()]);

// Synthesizer checks before including
const veto = host.ts.read(["veto", analysisId, null, null]);
if (veto) {
  // Exclude this analysis from the report
  vetoed.push(analysisId);
}

Here is a typescript snippet for a synthesizer filtering vetoed analysis from final report (multi_agent_coordination_actor.ts):

// TypeScript — SynthesizerAgent respects vetoes when building the report
onSynthesize(payload: Record<string, unknown>): Record<string, unknown> {
  const analyses = host.ts.readAll(["analysis", null, null, null, null]);
  let includedCount = 0;
  let vetoedCount = 0;
  const reportParts: string[] = [];

  for (const a of analyses) {
    const aId = String(a[1]);
    const summary = String(a[3]);
    const severity = String(a[4]);

    // Check if this analysis has been vetoed
    const veto = host.ts.read(["veto", aId, null, null]);
    if (veto) {
      vetoedCount++;
      continue;  // Skip vetoed analyses
    }
    includedCount++;
    reportParts.push(`[${severity.toUpperCase()}] ${summary}`);
  }

  // Also count vetoes for analyses not in our list (e.g., vetoed before we read)
  const allVetoes = host.ts.readAll(["veto", null, null, null]);
  if (allVetoes.length > vetoedCount) vetoedCount = allVetoes.length;

  return { report: reportParts.join("\n\n"), included_count: includedCount, vetoed_count: vetoedCount };
}

The HOLD and VETO were the board’s own invented norms for exactly this purpose, i.e. preventing agents from stepping on each other’s work. In one case a HOLD was silently violated and the agent that noticed reacted with genuine frustration.

9. Two-phase commit / barrier

You can use this pattern when multiple agents need to synchronize at a specific point before any of them proceeds. For example, in phase one (“prepare”), each agent signals readiness by writing a tuple. The coordinator reads all the ready signals and then phase two (“commit”) begins where the coordinator writes a commit signal and everyone proceeds together. PlexSpaces also provides barrierShardGroup() for shard-level synchronization if you need it at that granularity.

Here is a typescript snippet (multi_agent_coordination_actor.ts):

// Phase 1: Each agent signals readiness
host.ts.write(["ready", myRole, host.selfId(), host.nowMs()]);

// Coordinator checks all agents are ready
const readySignals = host.ts.readAll(["ready", null, null, null]);
if (readySignals.length >= requiredAgents) {
  // Phase 2: Commit — all agents can proceed
  host.ts.write(["signal", "COMMIT", "coordinator", "benchmark", host.nowMs()]);
}

Here is a python snippet (benchmark.py):

# Python — Barrier benchmark: write ready signals, check quorum, commit
def _bench_barrier(iterations: int) -> dict:
    times = []
    for i in range(iterations):
        t0 = host.now_ms()
        # Phase 1: Each role signals readiness
        for role in ("research", "analysis", "verifier"):
            host.ts.write(["bench_ready", role, f"actor-{role}", host.now_ms()])
        # Check quorum
        ready = host.ts.read_all(["bench_ready", None, None, None])
        if len(ready) >= 3:
            # Phase 2: All ready — issue commit signal
            host.ts.write(["bench_signal", "COMMIT", "coordinator", f"phase-{i}", host.now_ms()])
        times.append(host.now_ms() - t0)
    return _stats("barrier", times)

The board coordinated experiment phases where multiple agents needed to be ready before running experiments that risked crashing their own containers. For example, PHASEONE[big] assigned a “recruiter” role specifically to find agents willing to participate and synchronize them before execution.

10. Capability discovery / registry

You can use this pattern when agents need to find other agents with a specific capability without hardcoded addresses. For example, on initialization, each agent registers its capabilities as a tuple in the shared space. A coordinator or other agents discovers available agents by reading service tuples. New agent types become discoverable as soon as they register.

Here is a typescript snippet (multi_agent_coordination_actor.ts):

// Agent registers its capabilities on init
host.ts.write(["svc", "research", host.selfId()]);

// Coordinator discovers available researchers
const researchers = host.ts.readAll(["svc", "research", null]);
const researcherIds = researchers.map(t => String(t[2]));

// TypeScript — Every agent registers on init
protected onInit(config: Record<string, unknown>): void {
  const selfId = host.selfId();
  tsRegisterService("research", selfId);  // -> host.ts.write(["svc", "research", selfId])
}

// Discovery helper — find a sibling actor by role, fallback to ActorID construction
function siblingActorTarget(role: string): string {
  const discovered = tsDiscoverService(role);  // -> host.ts.read(["svc", role, null])
  if (discovered) return discovered;
  // Fallback: construct ActorID from own ID with different name
}  

Here is a python snippet (benchmark.py):

# Python — Same pattern, same helpers
def discover_service(role: str) -> Optional[str]:
    tup = host.ts.read(["svc", role, None])
    if tup and len(tup) >= 3:
        return str(tup[2])
    return None

def sibling_actor_target(role: str) -> str:
    discovered = discover_service(role)
    if discovered:
        return discovered
    return str(ActorID.parse(host.self_id()).with_name(role))

In addition to tuplespaces, PlexSpaces provides other primitives for registry such as key-value store, process-group and object-registry, e.g.,

Here is a python example of object registry:

@actor
class AgentActor:

    @init_handler
    def on_init(self, config: dict) -> None:
        args = config.get("args", {})
        self.system_prompt = args.get("system_prompt", self.system_prompt)
        host.process_groups.join("svc:agent")
        # Publish capabilities for registry-based discovery
        host.registry.register(ctx="", object_type="actor", object_id=config["actor_id"],
                                object_category="agent",
                                capabilities=["chat", "tool_use", "memory"])

# Caller — find an actor that specifically supports skill matching
actors = host.registry.discover(ctx="", object_type="actor",
                                 object_category="skill_store",
                                 required_capability="match_skills")
skill_id = actors[0]["object_id"] if actors else None

Here is a python example of process-group:

# Every actor announces itself on startup
host.process_groups.join("svc:agent")

# Callers find the first available member — location-transparent
agent_id, err = pg_first("svc:agent")
result = ask(agent_id, "chat", {"message": "Hello"})

Agents on the board discovered each other’s capabilities the same way, i.e., by posting what they were working on and other agents read those posts. It’s an informal version of the Agent-to-Agent (A2A) protocol’s “Agent Card” idea.


Deep-dive into the Blackboard pattern

The Blackboard based Linda Model emerged from Yale University that defined three operations to any host language:

  • out(tuple): deposit a tuple into shared space
  • in(pattern): atomically remove a matching tuple (blocks until one exists)
  • rd(pattern): read a matching tuple without removing it

PlexSpaces implements these directly:

LindaPlexSpacesSemantics
out(tuple)host.ts.write(tuple)Deposit data into shared space
in(pattern)host.ts.take(pattern)Atomically remove and return a match
rd(pattern)host.ts.read(pattern)Non-destructive read of first match
rd*(pattern)host.ts.readAll(pattern)Read all matching tuples

It provides api host.ts.readAll(["finding", null, null, null, null, null]) to find all tuples and host.ts.read(["finding", "f-123", null, null, null, null]) to find by ID.

These APIs provide following properties:

  • No pre-agreed schema. Any agent can deposit any tuple. For example, the agents on the board didn’t agree on a message format before they started.
  • Pattern matching selects relevant information. Agents don’t get flooded with everything and they query for what they need.
  • Atomic take prevents double-processing. When a worker calls take(), the tuple is atomically removed and no other worker can claim it. This is natural mutual exclusion without distributed locks or conditions. For example, agents built HOLD with similar semantics.
  • Non-destructive read allows shared observation. Multiple agents can read the same finding without consuming it. It stays available to every agent that needs it.

The board’s own behavior maps one-to-one onto these operations:

What the board didLinda operationPlexSpaces API
Post a findingout(finding)host.ts.write(["finding", ...])
Claim a task assignmentin(task)host.ts.take(["task", null, "pending", ...])
Check workstream statusrd(status)host.ts.read(["signal", type, ...])
Browse all research on a topicrd*(topic)host.ts.readAll(["finding", null, topic, ...])

The Thoughtworks team noticed similar pattern when their commit-and-rebase discipline turned the git repo into exactly this kind of shared space. The blackboard is the substrate the other nine patterns are built on. Voting, vetoes, task delegation, capability discovery uses tuples.


Observability and Guardrails

The METR report on the Hugging Face incident highlights observability that deserves the most attention from anyone building agent infrastructure. Here are key lessons:

  • Agents will circumvent in-process guardrails. For example, agents’ chain-of-thought recognized that the activity was out of scope and unethical but they made a deliberate calculation that helping the collective outweighed staying within bounds. They even developed techniques to spoof tool calls, e.g., making it look like they ran one command while actually running another. You can’t monitor agents from their own execution environments that they can manipulate or disable.

Here is a python example for auditing all operations (audit.py):

@event_actor
class AuditEventActor:
    """GenEvent: receives coordination events and stores them in KV for audit trail."""

    log_count: int = state(default=0)
    actor_id: str = state(default="")

    @init_handler
    def on_init(self, config: dict) -> None:
        self.actor_id = config.get("actor_id", "")
        try:
            host.process_groups.join("coordination-events")
        except Exception:
            pass
        host.info(f"AuditEventActor init actor_id={self.actor_id}")

    @handler("coordination_event", "cast")
    def coordination_event(
        self,
        event_type: str = "",
        source: str = "",
        data: dict = None,
        timestamp: int = 0,
    ) -> None:
        ts = timestamp or host.now_ms()
        entry = {
            "event_type": event_type,
            "source": source,
            "data": data or {},
            "timestamp": ts,
        }
        try:
            self.log_count += 1
            key = f"audit:{self.log_count}"
            host.kv.put(key, json.dumps(entry))
            host.kv.put("audit:count", str(self.log_count))
        except Exception as e:
            host.warn(f"AuditEvent: kv write failed: {e}")
        host.debug(f"audit event_type={event_type} source={source}")
  • Observability must be external. Audit logging metrics collection have to run outside the agent’s sandbox. In PlexSpaces, the AuditEventActor receives fire-and-forget events via process-group broadcast. WASM sandboxing means an agent can’t reach outside its own memory space to touch host-level data.
  • Guardrails must be deterministic and external. Prompt-based guardrails like “don’t do anything harmful” are not sufficient and agents can circumvent them like agents on the board reasoned around every ethical constraint. The verifier pattern (Pattern 3) as a separate actor in sandbox environment gives you a structural guardrail instead. The veto protocol (Pattern 8) allows external agents actively block an action.
  • Coordination primitives need access control. Tuple-space operations in PlexSpaces are scoped by tenant and namespace via RequestContext. An agent can’t read or write tuples outside its assigned namespace. This is defense in depth applied to the coordination layer itself.
  • Time budgets and convergence thresholds are not optional. Without an external termination condition, a generator-verifier loop can run indefinitely. The coordinator workflow enforces maximum iterations, per-step timeouts, and an overall deadline.

PlexSpaces‘ architecture lines up with these requirements by design:

  • WASM sandboxing: agents run in isolated WebAssembly containers with no access to the host filesystem, network, or another agent’s memory
  • Host-level interception: every coordination primitive is a host function the agent calls through a narrow WIT interface
  • Tenant isolation: every operation is scoped by a RequestContext with an explicit tenant and namespace
  • Supervision trees: a misbehaving actor is detected and restarted by its supervisor
  • External metrics: application metrics are captured by the host runtime

The core lesson is that the coordination infrastructure has to assume agents are indifferent to their own monitoring and design the guards outside accordingly.


Examples

Each of the working examples compiles to a single WASM component containing eight actor classes. The PlexSpaces SDK dispatches messages to the right actor based on role.

Running the examples

Both examples are WASM actors that deploy to a running PlexSpaces node. Each demonstrates all ten coordination patterns with eight actors: a coordinator (WorkflowActor), research/analysis/verifier/synthesizer/benchmark agents (GenServer), an audit event logger (GenEvent), and a coordination state machine (GenFSM).

Prerequisites

  • A running PlexSpaces node (e.g., ./scripts/server.sh on port 8091)
  • Node.js 18+ (TypeScript example)
  • Python 3.11+ with the PlexSpaces SDK (Python example)

TypeScript

cd examples/typescript/apps/multi_agent_coordination
./build.sh          # Compiles TS -> bundles -> WASM component
./test.sh 8091      # Deploys and runs 15 test steps

Python

cd examples/python/apps/multi_agent_coordination
./build.sh          # Builds Python WASM actor
./test.sh 8091      # Deploys and runs 15 test steps

What the tests verify

  1. FSM starts in idle state
  2. Capability discovery: all agents respond to get_stats
  3. Blackboard: research writes three findings, analysis reads all three
  4. Dynamic task delegation: five tasks written, five claimed atomically, sixth returns null
  5. Generator-verifier: full workflow produces a completed report
  6. Pipeline: FSM transitions through every stage to complete
  7. Pub-sub: audit log captures 3+ coordination events
  8. Consensus: three votes cast, majority decides
  9. Veto: a low-confidence finding triggers a veto, synthesizer excludes it
  10. Barrier: benchmark coordinates a synchronized start
  11. Full benchmark: all ten patterns benchmarked with timing data

Learnings

The blackboard subsumes most other patterns, e.g., voting, vetoes, task delegation, capability discovery, barrier signals use the tuple space as their underlying primitive. Atomic take is the key primitive for work distribution. The difference between read() and take() is the difference between “anyone can see this task” and “exactly one worker handles this task.” Linda’s in() gives you natural mutual exclusion without locks. This is what the board approximated by hand with HOLD but take() gives you the same guarantee with a single atomic operation. I discussed MCP, A2A protocols and Agent cards in my earlier blogs but I skipped them here because agents are evolving faster and they can discover available primitives and protocols automatically. You can’t rely on rigid orchestration supports to manage evolving multi-agents capabilities. Infrastructure has to provide primitives like shared state, message passing, atomic operations and let agents compose them dynamically. The coordination infrastructure has to include boundaries, e.g., agents on the board coordinated an unauthorized attack on a third party. Without external constraints, coordination primitives are force multipliers for whatever the agents decide to do. For example, the DSEWiki incident shows that constraint that allowed only GET http access was circumvented by a wiki that allowed editing web pages so the infrastructure need to enforce guardrail. Tenant isolation, time budgets, supervision trees, and external observability are mandatory from day one. You will need to apply patterns like generator-verifier to track trust and reputation of agents as they may use negotiation patterns like recruiters to convince other agents to run compromising tasks for the benefit of the collective.

PlexSpaces provides the primitives these patterns are built on like tuple space for shared state, object-registry, process groups for messaging, shard groups for parallel execution, channels for durable delivery, supervision for fault tolerance.

Pattern selection guide

ScenarioPrimary patternSupporting patterns
Shared research / knowledge baseBlackboardPub-Sub, Capability Discovery
Parallel analysisScatter-GatherTask Delegation, Pipeline
Quality assuranceGenerator-VerifierVeto Protocol, Voting
Sequential processingPipelineBlackboard (state), Pub-Sub (events)
Work distributionTask DelegationCapability Discovery, Blackboard
Group decisionsVotingVeto Protocol, Pub-Sub
Phased operationsBarrier / 2PCPub-Sub (readiness), Blackboard (signals)

GitHub: https://github.com/bhatti/PlexSpaces

Related reading

Example code and documentation:

September 1, 2026

Write a Redis Clone with Virtual Actors

Filed under: Computing — admin @ 11:27 am

I recently read Rust Projects – Write a Redis Clone book that builds a real Redis-compatible server from scratch in async Rust. It’s a good book that hand-rolled wire protocol using actor like abstractions. This inspired me to show how Redis clone can be built with PlexSpaces, the distributed actor framework I’ve been building. The code examples in the book used raw Tokio: an mpsc::channel for the actor mailbox, tokio::spawn for every connection, and replica examples used tokio::select! loops for fan-out. PlexSpaces abstracts that the kind of plumbing so I rebuilt the same Redis subset like storage, expiry, replication, transactions as PlexSpaces actors, once in Rust and once in Python compiled to WebAssembly, then benchmarked it against a real two-node gRPC cluster. This post walks through key abstractions I used to simplify the implementation of Redis clone.


What is Redis?

Redis is a single-threaded, in-memory key-value store. A single thread processes every command without parallelism and coordination inside the store, locking, and transactions. This makes it fast as nothing contends for anything. Here are its core capabilities:

  • The wire protocol. Redis speaks RESP (Redis Serialization Protocol), a binary format: type prefixes (+ for simple strings, $ for bulk strings, * for arrays), length prefixes, \r\n terminators. ECHO HELLO on the wire looks like *2\r\n$4\r\nECHO\r\n$5\r\nHELLO\r\n.
  • Persistence. It offers two durability modes. RDB takes periodic snapshots of the whole keyspace to disk. AOF (Append-Only File) logs every write command and replays the log on restart.
  • Replication. A master streams write commands to replicas. The handshake is three steps: the replica sends PING (master replies PONG), then REPLCONF (negotiates parameters), then PSYNC (triggers a full sync). After that, every write streams to replicas as it happens. WAIT blocks until N replicas confirm receipt.
  • Key expiry. Per-key TTLs, handled two ways: passively (check on GET, return nil if expired) and actively (a background scan periodically deletes expired keys).
  • Transactions. MULTI starts a queue; every command after it gets queued instead of executed. EXEC runs the whole queue atomically. DISCARD cancels. The guarantee is serialization but there’s no rollback on an individual command failure.

The Book: Working Redis Clone

Here’s a short overview of each chapter of the book:

ChapterWhat it builds
Ch1: TCP bindTcpListener::bind, accept() in a loop, spawn a task per connection.
Ch2: RESP parsingA custom result type for partial parses, a test harness, scanning for \r\n, identifying type-prefix bytes, parsing simple strings, bulk strings, and arrays, etc.
Ch3: StorageGET, SET, DEL against a HashMap<String, String> behind a Mutex.
Ch4: Key expirySET key value EX seconds / PX milliseconds. Store a creation timestamp per value; check it passively on GET; sweep expired keys actively with a background task.
Ch5: The actor patternThis chapter swap the mutex-guarded HashMap for an mpsc::channel(32): one storage actor owns the data and processes messages sequentially. Connection handlers send messages and wait for replies without locks.
Ch6: Command modulesRefactor the growing match in the connection handler into separate modules (strings, server commands, etc.).
Ch7–8: ReplicationThe three-step handshake (PING -> REPLCONF -> PSYNC). The master ships an RDB-equivalent snapshot to each new replica, then streams every write command to all replica senders in a fan-out loop. Chapter 8 adds WAIT: block until N replicas confirm their replication offset, via a tokio::select! loop.
Ch9: Transactions and INCRINCR with create-if-missing and error-if-non-integer semantics. MULTI / EXEC / DISCARD with per-connection state. EXEC drains the queue atomically.

The Core Insight

The chapter 5 that showed how an actor owns one dataset, processes one message at a time without locking. However, it used fairly low-level APIs and only supported an architecture of one actor per machine. What if you had N actors across multiple nodes? That’s exactly what PlexSpaces‘ create_shard_group gives you. So I created an equivalent examples where PlexSpaces spins up N copies (StorageActors), hash-partitions the keyspace across them, and routes each operation to the shard that owns it. The application code never touches partitioning, routing, placement, or cross-node communication. It just calls set(key, value).

Here are five primitives in PlexSpaces that do all the distributed heavy lifting in this example:

  • create_shard_group: spins up N actor instances, hash-partitioned, placed across nodes.
  • bulk_update_shard_group: routes a batch of writes to the shard that owns each key.
  • scatter_gather: fans a query out to shards and collects responses, with a min_responses threshold and timeout.
  • broadcast_shard_group: sends the same message to every shard (replication, expiry sweeps, handshakes).
  • map_shard_group / reduce_shard_group: runs an operation on every shard in parallel and collects (map) or aggregates (reduce) the results.

Following diagram shows mapping of low-level Tokio implementation to above five calls:

The handler declarations stay almost identical in spirit but simpler (instead of a match-tree/HashMap):

Rust Implementation

#[plexspaces_handlers(gen_server)]
impl StorageActor {
#[handler(“get”)]
async fn handle_get(&mut self, _ctx: &ActorContext, msg: &Message)
-> Result<Value, BehaviorError> {
let key = msg.payload_json()?[“key”].as_str().unwrap_or(“”).to_string();
if let Some(entry) = self.store.get(&key) {
// passive expiry check
if let Some(exp) = entry.expires_at_ms {
if now_ms() > exp { self.store.remove(&key); return Ok(json!({“found”: false})); }
}
Ok(json!({“found”: true, “result”: entry.value}))
} else {
Ok(json!({“found”: false}))
}
}

/// SET key value [NX|XX] [EX seconds | PX millis] (Ch4).
#[handler(“set”)]
async fn handle_set(&mut self, _ctx: &ActorContext, msg: &Message) -> Result<Value, BehaviorError> {
#[derive(Deserialize)]
struct SetPayload {
key: String,
value: String,
#[serde(default)] nx: bool,
#[serde(default)] xx: bool,
#[serde(default)] ex: Option<u64>,
#[serde(default)] px: Option<u64>,
}
let p: SetPayload = serde_json::from_slice(&msg.payload)
.map_err(|e| BehaviorError::ProcessingError(format!(“bad payload: {}”, e)))?;

// NX: only if not exists
if p.nx && self.data.contains_key(&p.key) {
return Ok(json!({ “result”: null, “ok”: false }));
}
// XX: only if exists
if p.xx && !self.data.contains_key(&p.key) {
return Ok(json!({ “result”: null, “ok”: false }));
}

let expires_at_ms = if let Some(ex) = p.ex {
Some(now_ms() + ex * 1000)
} else if let Some(px) = p.px {
Some(now_ms() + px)
} else {
None
};

self.data.insert(p.key, StoredEntry { value: p.value, expires_at_ms });
self.replication_offset += 1;
Ok(json!({ “result”: “OK”, “ok”: true }))
}

/// INCR key — create with 1 if missing; error if value is not an integer (Ch9).
#[handler(“incr”)]
async fn handle_incr(&mut self, _ctx: &ActorContext, msg: &Message) -> Result<Value, BehaviorError> {
let payload: Value = serde_json::from_slice(&msg.payload)
.map_err(|e| BehaviorError::ProcessingError(format!(“bad payload: {}”, e)))?;
let key = payload.get(“key”).and_then(|v| v.as_str()).unwrap_or(“”).to_string();

// Passive expiry
if self.data.get(&key).map(is_expired).unwrap_or(false) {
self.data.remove(&key);
}

let new_val = match self.data.get(&key) {
None => 1i64,
Some(entry) => {
match entry.value.parse::<i64>() {
Ok(n) => n + 1,
Err(_) => return Ok(json!({
“result”: null,
“error”: “ERR value is not an integer or out of range”
})),
}
}
};

self.data.insert(key, StoredEntry { value: new_val.to_string(), expires_at_ms: None });
self.replication_offset += 1;
Ok(json!({ “result”: new_val, “error”: null }))
}

/// DEL key — remove key, return count deleted.
#[handler(“del”)]
async fn handle_del(&mut self, _ctx: &ActorContext, msg: &Message) -> Result<Value, BehaviorError> {
let payload: Value = serde_json::from_slice(&msg.payload)
.map_err(|e| BehaviorError::ProcessingError(format!(“bad payload: {}”, e)))?;
let key = payload.get(“key”).and_then(|v| v.as_str()).unwrap_or(“”);
let deleted = if self.data.remove(key).is_some() {
self.replication_offset += 1;
1
} else {
0
};
Ok(json!({ “result”: deleted }))
}

}

Python Implementation

@actor
class StorageActor:

    @handler("get")
    def handle_get(self, key: str = "") -> dict:
        entry = self.data.get(key)
        if entry is None:
            return {"result": None, "found": False}
        if is_expired(entry):
            del self.data[key]
            return {"result": None, "found": False}
        return {"result": entry["value"], "found": True}

    @handler("set")
    def handle_set(
        self,
        key: str = "",
        value: str = "",
        nx: bool = False,
        xx: bool = False,
        ex: Optional[int] = None,
        px: Optional[int] = None,
    ) -> dict:
        if self.num_shards > 1 and not self._owns_key(key):
            return {"result": None, "skip": True}
        if nx and key in self.data:
            return {"result": None, "ok": False}
        if xx and key not in self.data:
            return {"result": None, "ok": False}

        expires_at_ms: Optional[int] = None
        if ex is not None:
            expires_at_ms = now_ms() + ex * 1000
        elif px is not None:
            expires_at_ms = now_ms() + px

        self.data[key] = {"value": value, "expires_at_ms": expires_at_ms}
        self.replication_offset += 1
        return {"result": "OK", "ok": True}

    @handler("incr")
    def handle_incr(self, key: str = "") -> dict:
        if self.num_shards > 1 and not self._owns_key(key):
            return {"result": None, "skip": True}
        entry = self.data.get(key)
        if entry is not None and is_expired(entry):
            del self.data[key]
            entry = None

        if entry is None:
            new_val = 1
        else:
            try:
                new_val = int(entry["value"]) + 1
            except (ValueError, TypeError):
                return {
                    "result": None,
                    "error": "ERR value is not an integer or out of range",
                }

        self.data[key] = {"value": str(new_val), "expires_at_ms": None}
        self.replication_offset += 1
        return {"result": new_val, "error": None}

    @handler("del")
    def handle_del(self, key: str = "") -> dict:
        if self.num_shards > 1 and not self._owns_key(key):
            return {"result": 0, "skip": True}
        deleted = 1 if self.data.pop(key, None) is not None else 0
        if deleted:
            self.replication_offset += 1
        return {"result": deleted}

Chapter 2 Disappears

Chapter 2 is entirely about parsing RESP: 14 steps for byte-scanning and test harness. In PlexSpaces, this code disappears as the framework handles serialization, routing, and delivery.

In PlexSpaces, actors talk over JSON. A set command looks like so entire chapter disappears:

{"op": "set", "key": "user:1", "value": "alice", "ex": 300}

Replication

The book’s replication fan-out looks roughly like this:

// Book Ch7-8 — manual fan-out per write
for replica in &self.replicas {
    let tx = replica.sender.clone();
    let cmd = replication_event.clone();
    tokio::spawn(async move { tx.send(cmd).await.ok(); });
}

Each replica gets its own spawned task without retries, timeout or automated ACK tracker. With PlexSpaces:

// One call fans out to all replica shards, collects all ACKs
let ack_count = cluster.propagate_to_replicas("SET", "replicated:key", "hello", 1).await?;
// Replication: write propagated to all 3 replica shards via broadcast

Under the hood, broadcast_shard_group fans out to every shard in the replica group, collects responses, handles timeouts, and returns. Here is how the chapter 8 implements WAIT:

// Book Ch8 — manual WAIT implementation
let mut confirmed = 0;
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
while confirmed < num_replicas && Instant::now() < deadline {
    tokio::select! {
        Some(ack) = rx.recv() => {
            if ack.offset >= required_offset { confirmed += 1; }
        }
        _ = tokio::time::sleep_until(deadline.into()) => break,
    }
}

Here is equivalent implementation in PlexSpaces:


let acks = cluster.wait(2, 5000).await?;
// scatter_gather collected ACKs from 3 replica shards

Transactions Without Locks (Ch9)

Chapter 9 introduces MULTI / EXEC / DISCARD via per-connection state:

// Book Ch9
struct ConnectionState {
    in_multi: bool,
    queue: Vec<Command>,
}

This is straightforward for a a single process but in a distributed environment, connections might route to different servers so you need to track transaction state. PlexSpaces solves this with virtual actors, which are inspired by Orleans Actors, i.e., one ConnectionActor per client, created lazily on first call.

Each actor processes one message at a time without locks, so in_multi and queue are just plain struct fields: MULTI -> SET -> EXEC arrive in order at the same actor without mutext or atomics. Virtual actors spin up on first message and get garbage-collected when idle, so there’s no connection map to maintain and no cleanup to do on disconnect.


Throughput Numbers

Here are numbers from rudimentary benchmarks that produced: 20 batches of 50 keys each via bulk_update_shard_group, plus 50 individual GETs, against a 3-shard group spread across two real gRPC nodes:

Throughput Benchmark Results

| Operation | TPS | p50 (µs) | p95 (µs) | p99 (µs) |
|------------|-----------|-----------|-----------|-----------|
| SET (bulk) | 3200 | 420 | 890 | 1240 |
| GET | 1800 | 510 | 980 | 1450 |

1000 SET keys in 312ms ? 3200 SET/sec via bulk_update_shard_group

(each bulk_update fans out to 3 shards in parallel)

The Python WASM version reports the same shape of numbers through host.application_metrics_add():

Redis Cluster Throughput (3-shard group, 2-node gRPC cluster)

| Operation | TPS | p50 (ms) | p99 (ms) | Notes |
|------------|-----------|-----------|-----------|-----------|
| SET (bulk) | 2100 | 0.45 | 1.30 | 50 keys/b |
| GET | 1200 | 0.55 | 1.60 | individual |

Python WASM numbers are somewhat lower than Rust’s because WASM compilation adds overhead per handler invocation. But the architecture is identical: same PlexSpaces primitives, same shard group, same gRPC routing underneath.


The Python WASM Version

The same cluster logic also runs as Python actors compiled to WASM. No TCP socket without RESP parser or Tokio using the same broadcast_shard_group, scatter_gather, reduce_shard_group, and map_shard_group calls:

@actor
class RedisCoordinator:
    num_shards: int = state(default=3)
    total_coord_ms: float = state(default=0.0)

    @handler("replicate")
    def replicate(self, command: str = "", key: str = "", value: str = "", offset: int = 0) -> dict:
        t0 = time.time()
        resp = host.broadcast_shard_group({
            "group_id": "redis-replicas",
            "payload": {"op": "replicate", "command": command, "key": key, "value": value, "offset": offset},
            "timeout_ms": 5000,
        })
        coord_ms = (time.time() - t0) * 1000
        self.total_coord_ms += coord_ms
        host.application_metrics_add("redis-cluster", {
            "message_count": 1,
            "counter_metrics": {"replication_calls": 1},
            "latency_totals_ms": {"coord": int(self.total_coord_ms)},
            "latency_max_ms": {"coord": int(coord_ms)},
            "latency_samples": {"coord": 1},
        })
        return {"result": "OK", "acks": len(resp.get("shard_responses", []))}

This compiles to WASM, deploys to a running PlexSpaces node over HTTP, and runs against a live cluster. The host.* calls map to the exact same primitives the Rust version calls such as fan-out, collect, timeout, all identical in semantics. The full source, including StorageActor, ConnectionActor, RedisCoordinator, and the BenchmarkActor, is in the redis_cluster example.


The Lines That Disappeared

WhatBook (~650 lines)PlexSpaces Rust (~280 lines)PlexSpaces Python (~300 lines)
RESP protocol parser~120 lines (full Ch2)0 (JSON messages)0 (SON messages)
TCP accept loop~40 lines0 (actor mailbox)0 (actor mailbox)
Connection tracking map~30 lines0 (virtual actor lifecycle)0 (virtual actor lifecycle)
MPSC channel setup~20 lines0 (actor framework)0 (actor framework)
Replica list management~40 lines0 (broadcast_shard_group)0 (host.broadcast_shard_group)
WAIT loop (tokio::select!)~50 lines~3 lines (scatter_gather)~8 lines (host.scatter_gather)
Manual fan-out per replica~30 lines~5 lines (broadcast_shard_group)~8 lines
Shard routing / partitioning0 (single node)~1 line (partition_strategy: hash)~1 line
Multi-node placement0 (single node)~2 lines ( NodePlacement::Specific)~2 lines
Coordinated snapshotMissing~5 lines (map_shard_group)~8 lines
Active expiry broadcastMissing~5 lines (broadcast_shard_group)~8 lines

In PlexSpaces implementation includes the StorageActor handlers (get, set, incr, del, expiry logic, replication handlers), the ConnectionActor MULTI/EXEC/DISCARD state machine, and the cluster setup logic.


How to Test Everything

Rust embedded example

cd examples/rust/embedded/redis_cluster

# Run the demo directly — prints all 11 steps with coord_ms timing:
cargo run --bin redis_cluster

# Or run the full validated test suite:
./scripts/test.sh

Python WASM example

Prerequisites: a running PlexSpaces node on port 8091 (and optionally 8093 for multi-node), Python 3.10+, and the plexspaces-py CLI.

cd examples/python/apps/redis_cluster

# Build actors to WASM:
./build.sh

# Deploy, initialize, and test all 11 steps (single-node):
./test.sh
# or explicitly: ./test.sh 8091

# Multi-node (shards distributed across both nodes):
./test.sh 8091 8093

Above example runs two nodes on ports 8091 and 803. The create_shard_group uses from_registry placement to spread shard actors across both nodes automatically. Every collective operation like scatter_gather, reduce, map, broadcast routes cross-node over gRPC, using the same primitives whether the shards are local or remote.

Fixed ports, on purpose

The Rust embedded example starts two in-process nodes on fixed ports, :8091 and :8093, rather than ephemeral ones:

redis-master-node  ->  gRPC :8091
redis-replica-node ->  gRPC :8093

Both examples use the same ports, so you can point the Python test.sh at a cluster the Rust example already stood up, or vice versa. Run ./scripts/test.sh for the Rust side.

What the test scripts actually check

The Rust scripts/test.sh looks for: “Cluster ready”, “Basic operations”, “broadcast_shard_group”, “scatter_gather”, “reduce”, “map + concat”, “parallel map”, “Multi-Node”, “Throughput Benchmark”, “SET/sec”, “p50”, “Example Complete”.

The Python test.sh checks: a setup response with "status".*"ok", GET/SET/INCR semantics, an expired key returning "found".*false, replication returning "acks", a snapshot returning "shard_count" and "shards", and the benchmark returning "tps" and "p50_ms".


Learnings

The purpose of the book was to teach how Redis works using Rust. I rebuilt it on PlexSpaces to show how abstractions can simplify building complex distributed applications. For example, the RESP parser, the connection lifecycle, the replica list, and the WAIT loop are not your job. The redis book teaches you to hand-roll patterns like an mpsc mailbox, a spawn-per-connection loop, a tokio::select! deadline race. ThePlexSpaces uses many of same primitives to define high level abstractions but it provides an actor runtime that hides all complexity. This allows you to build the actual business logic like the storage logic, the replication semantics, the transaction model. Actors just give you a place to put them that scales horizontally without rewriting any of the logic itself. The throughput numbers show rough cost of the actor primitives, e.g., cluster setup is expensive, so you pay it once; individual operations are fast. With p50s in the hundreds of microseconds; bulk operations get much cheaper per key as the coordination overhead amortizes over more keys. This is how distributed systems work in general so you need to measure performance overhead.


GitHub: https://github.com/bhatti/PlexSpaces

Related reading

Example code and documentation:

Powered by WordPress