Procházet zdrojové kódy

M12 plan: K8s + multi-broker NATS, 50k/s ceiling

M12 is the v2 milestone that addresses the M11 conditional-ship
finding (NATS JetStream resource limits exceeded under sustained
load, exposed by the 2-min re-verification 2026-06-16 14:48 EDT).
Single-broker NATS on docker-compose cannot deliver v1 capacity;
M10-bench only proved the router ceiling, not the broker ceiling.

Plan covers 6 workstreams:
  W1 K8s manifests (Deployments, StatefulSets, Services)
  W2 NATS JetStream cluster (3 brokers, R=3 streams, chaos test)
  W3 Helm chart + GitOps (values per env, Argo CD or Flux)
  W4 50k/s ceiling bench (M10-bench on K8s, 5/5 green runs)
  W5 M11 prod gate on K8s (10k/s x 10 min, full 5-step smoke)
  W6 CI/CD + observability for K8s

Timeline: ~6-8 weeks with one full-time engineer.

M12 cannot start until:
  1. M11 NATS resource limit issue is resolved (W5's new
     publish-success assertion will fail on single-broker)
  2. M11 loadgen exit-code issue is resolved (full 5-step smoke
     needs a green backpressure step)
  3. K8s cluster is provisioned (parres can host minikube for dev;
     staging + prod clusters need to be decided)
  4. Container registry is ready (registry.techno-world.net/...)

Appendix A documents the 3 options for pushing the current M11 work
to git3 (origin). The local and origin histories diverged at
09f0d54; recommended approach is A1 (merge origin/master into
local, then push) which preserves both sets of commits.
Luis Rosales před 1 měsícem
rodič
revize
80879e915e
1 změnil soubory, kde provedl 500 přidání a 0 odebrání
  1. 500 0
      M12_PLAN.md

+ 500 - 0
M12_PLAN.md

@@ -0,0 +1,500 @@
+# M12 — Detailed Implementation Plan
+
+**Status:** planning (post-M11-conditional, blocked on NATS issue)
+**Target:** M12 exit criterion from SPEC §19
+**Goal:** Move from single-broker docker-compose to K8s-deployed multi-broker NATS, sustaining 50k/s end-to-end with M11 prod gate (10k/s sustained 10 min, p99 ≤ 50ms, DLQ=0) on a single ingestd instance.
+
+---
+
+## 0. Recap — what M12 must prove
+
+| Milestone | Exit criterion | How it's measured |
+|---|---|---|
+| **M11 prod gate** (carried into M12) | internal Go service pushes ≥ 10k alerts/sec on one gRPC stream, p99 server-side `Ack` ≤ 50ms, DLQ=0, sustained 10 min | `m11_smoke.py` on K8s cluster, asserts on receive rate **and** publish success |
+| **M12 ceiling** (new) | 50k/s end-to-end through multi-broker NATS + multi-ingestd, p99 router latency ≤ 50ms, no broker backpressure | `m10_bench_smoke.py` (adapted) on K8s, delivery stubbed |
+| **M12 cluster** (new) | NATS cluster survives single-broker loss without message loss or sustained publish failure | chaos test: kill one NATS pod mid-soak, verify p99 stays under 100ms and no DLQ |
+
+**Why M12 now (not later):** The 2-min re-verification on 2026-06-16 14:48 EDT exposed the single-NATS broker as the system bottleneck. NATS logged `[ERR] JetStream resource limits exceeded for server` every 10s under sustained ~6k/s load, and the receive rate collapsed to 0/s in 30s. Single-broker NATS on docker-compose cannot deliver v1 capacity (5k/s sustained per the SPEC, design ceiling 50k/s). M10-bench proved the **router** ceiling at 50k/s with delivery stubbed, but the broker was the implicit weak link — M12 surfaces and fixes it.
+
+---
+
+## 1. Workstream overview
+
+```
+┌──────────────────────────┐    ┌──────────────────────────┐    ┌──────────────────────────┐
+│  W1: K8s manifests       │    │  W2: NATS JetStream      │    │  W3: Helm chart +        │
+│  (Deployments, Services, │───▶│  cluster (3+ brokers,    │───▶│  argocd / flux /        │
+│   ConfigMaps, Secrets)   │    │   R3, clustering)        │    │  plain kubectl           │
+└──────────────────────────┘    └──────────────┬───────────┘    └──────────────┬───────────┘
+                                               │                               │
+                                               ▼                               ▼
+                                     ┌──────────────────────────┐    ┌──────────────────────────┐
+                                     │  W4: 50k/s ceiling bench │    │  W5: M11 prod gate on K8s│
+                                     │  (M10-bench equivalent)  │    │  (10k/s × 10 min)        │
+                                     │  delivery stubbed        │    │  full smoke (5 steps)    │
+                                     └──────────────────────────┘    └──────────────────────────┘
+                                               │                               │
+                                               ▼                               ▼
+                                     ┌──────────────────────────────────────────────────┐
+                                     │  W6: CI/CD + observability for K8s              │
+                                     │  (GitHub Actions / GitLab CI build+push,        │
+                                     │   Prometheus Operator, Grafana dashboards)       │
+                                     └──────────────────────────────────────────────────┘
+```
+
+Six workstreams, each ends with passing evidence before the next starts.
+
+---
+
+## 2. The four layers of risk (and what each WS defuses)
+
+| Risk | Why it's scary | How we defuses it |
+|---|---|---|
+| **Single NATS broker is the bottleneck** (post-M11-conditional finding) | M11 10-min soak was green only because the rate metric counts gRPC receive, not publish success. Real-world delivery is broken under sustained load. | W2: NATS cluster (3+ brokers, R=3 JetStream streams) raises the limit ceiling by ~3x. W4: prove the cluster ceiling at 50k/s. |
+| **K8s deployment is operationally complex** | K8s has a learning curve; mistakes (wrong resource limits, missing healthchecks, bad ConfigMap wiring) cause silent failures | W1: manifests mirror docker-compose service-by-service; W3: Helm chart encapsulates complexity; W6: GitOps pipeline means config changes are reviewable in PRs. |
+| **Stateful workloads in K8s** | NATS, Postgres, ClickHouse, Redis all need persistent volumes and ordered shutdown. Compose just restarts; K8s needs StatefulSets + PodDisruptionBudgets. | W1: use StatefulSets for stateful services, Deployments for stateless (ingestd, routerd, deliverd-*). W3: Helm chart includes PVC templates + PDBs. |
+| **Migrating from docker-compose to K8s in production** | Compose works; K8s is new. Risk of breaking the dev loop. | W1 keeps docker-compose as the dev story (unchanged), K8s is the deploy target only. M11+M12 work continues against compose. |
+
+---
+
+## 3. Workstream W1 — K8s manifests
+
+### 3.1 Repo additions
+
+```
+deploy/k8s/
+  base/
+    namespace.yaml                # broad-announce namespace
+    configmap.yaml                # BA_* env vars
+    secrets.yaml.example          # fcm-credentials, telegram-bot-token, etc. (gitignored real one)
+  postgres/
+    statefulset.yaml              # 1 replica, 10Gi PVC, healthcheck
+    service.yaml
+    pdb.yaml                      # minAvailable: 1
+  nats/                           # see W2 for cluster specifics
+    statefulset.yaml
+    headless-service.yaml
+    configmap.yaml
+    pdb.yaml
+  redis/
+    statefulset.yaml
+    service.yaml
+  clickhouse/
+    statefulset.yaml
+    service.yaml
+  emqx/
+    statefulset.yaml              # 1 replica (single-node OK for v2.0)
+    service.yaml
+  ingestd/
+    deployment.yaml               # 2 replicas, max_inflight=256, BA_INGESTD_RATE_LIMIT_PER_SOURCE=20000
+    service.yaml                  # ClusterIP, port 8800 + 9090 (gRPC)
+    hpa.yaml                      # autoscale on CPU + custom metric (alerts_received rate)
+  routerd/
+    deployment.yaml
+    service.yaml
+  deliverd-fcm/
+    deployment.yaml
+    service.yaml
+  deliverd-telegram/
+    deployment.yaml
+    service.yaml
+  admind/
+    deployment.yaml
+    service.yaml
+  archiverd/
+    deployment.yaml
+    service.yaml
+  loadgen/                        # kept as Deployments, scaled up for the 50k/s bench
+    deployment.yaml
+```
+
+### 3.2 Conversion rules (compose → K8s)
+
+| compose | K8s |
+|---|---|
+| `service: ingestd` | `Deployment` named `ingestd`, `Service` ClusterIP, port 8800+9090 |
+| `environment:` block | `ConfigMap` mounted as envFrom |
+| `volumes: ["/data"]` | `StatefulSet` + `volumeClaimTemplates` with PVCs |
+| `healthcheck: test: ...` | `livenessProbe` + `readinessProbe` (exec or httpGet) |
+| `depends_on: [postgres]` | Same effect via init containers (wait for postgres:5432) or K8s-native service discovery with retries in app code |
+| `ports: ["8800:8800"]` | `Service` (ClusterIP) + optional `Ingress` for external |
+| `deploy.resources.limits` | `resources.limits` in container spec |
+| profiles (loadgen-grpc) | `Deployment` in `deploy/k8s/overlays/loadgen/` (Kustomize) — off by default in base |
+
+### 3.3 Exit criteria for W1
+
+- All services from `docker-compose.yml` have K8s manifests in `deploy/k8s/base/`
+- `kubectl apply -k deploy/k8s/base/` brings the stack up
+- `kubectl get pods -n broad-announce` shows all services `Running` with `Ready 1/1` (or appropriate for stateful)
+- Service-to-service DNS works (e.g., ingestd can reach nats:4222)
+- The M11 10-min soak passes against the K8s deployment (regression check: nothing broke in the move)
+
+---
+
+## 4. Workstream W2 — NATS JetStream cluster
+
+### 4.1 Why this is its own workstream
+
+The 2026-06-16 14:48 EDT finding shows single-broker NATS hits resource limits under sustained ~6k/s. Multi-broker is M12's core fix, not a deployment detail.
+
+### 4.2 Cluster topology
+
+- **3 brokers** (`nats-0`, `nats-1`, `nats-2`) on separate nodes (anti-affinity)
+- **JetStream** enabled on all 3
+- **Stream `ALERTS`** with replicas=3, storage=File, max_age=24h
+- **Subjects:** `alerts.<company_id>.<source_id>` (per ARCHITECTURE.md §6)
+- **Consumer `ROUTER`** for routerd, replicas=3, ack_wait=30s, max_deliver=5
+- **Consumer `DELIVERY_FCM`** / `DELIVERY_TELEGRAM` for deliverd-*, replicas=3
+
+### 4.3 Config
+
+```yaml
+# deploy/k8s/base/nats/configmap.yaml
+cluster:
+  name: broad-announce-nats
+  listen: 0.0.0.0:6222
+  routes:
+    - nats-broad-announce-nats-0.nats-headless:6222
+    - nats-broad-announce-nats-1.nats-headless:6222
+    - nats-broad-announce-nats-2.nats-headless:6222
+jetstream:
+  store_dir: /data/jetstream
+  max_memory_store: 2Gi
+  max_file_store: 100Gi
+```
+
+### 4.4 Capacity target (post-cluster)
+
+| Stream | Per-broker | Cluster (R=3) |
+|---|---|---|
+| `ALERTS` throughput | ~20k/s | ~50-60k/s (3 brokers share load) |
+| `ALERTS` storage | 100Gi | 300Gi (file-backed) |
+| `ALERTS` max memory | 2Gi | 6Gi |
+
+This raises the resource-limit ceiling by ~3x. The 50k/s ceiling bench (W4) verifies it.
+
+### 4.5 Exit criteria for W2
+
+- 3-broker NATS cluster running in K8s
+- `nats stream info ALERTS` shows replicas=3, leader distributed
+- Subject routing works: publish to `alerts.acme-001` on any broker lands in the stream
+- Single-broker loss does not cause message loss (chaos test)
+
+---
+
+## 5. Workstream W3 — Helm chart + GitOps
+
+### 5.1 Why a Helm chart
+
+- Parameterization (replica counts, image tags, env vars) per environment
+- Encapsulates the manifest sprawl from W1
+- Standard for K8s packaging
+
+### 5.2 Repo additions
+
+```
+deploy/helm/
+  broad-announce/
+    Chart.yaml
+    values.yaml                  # defaults: dev-shape cluster, single NATS
+    values.prod.yaml             # prod-shape: 3 NATS, multi-ingestd, higher resource limits
+    templates/
+      _helpers.tpl
+      namespace.yaml
+      configmap.yaml
+      secrets.yaml
+      postgres/
+      nats/
+      redis/
+      clickhouse/
+      emqx/
+      ingestd/
+      routerd/
+      deliverd-fcm/
+      deliverd-telegram/
+      admind/
+      archiverd/
+```
+
+### 5.3 Values structure
+
+```yaml
+# values.prod.yaml
+global:
+  imageRegistry: registry.techno-world.net/lrosales
+  imageTag: "M12-W6"
+
+nats:
+  cluster:
+    enabled: true
+    replicas: 3
+    storage: 100Gi
+  jetstream:
+    maxMemory: 2Gi
+    maxFile: 100Gi
+
+ingestd:
+  replicas: 3
+  resources:
+    requests: {cpu: "500m", memory: "512Mi"}
+    limits:   {cpu: "2",    memory: "1Gi"}
+  env:
+    BA_INGESTD_RATE_LIMIT_PER_SOURCE: "20000"
+    BA_INGESTD_MAX_INFLIGHT: "256"
+
+routerd:
+  replicas: 2
+  ...
+```
+
+### 5.4 GitOps (optional but recommended)
+
+- **Argo CD** or **Flux** watches `deploy/helm/broad-announce/`
+- PRs to `values.prod.yaml` trigger preview environments
+- Auto-sync to prod after approval
+
+### 5.5 Exit criteria for W3
+
+- `helm install broad-announce deploy/helm/broad-announce/ -f values.prod.yaml -n broad-announce` brings up the prod-shape cluster
+- `helm upgrade --reuse-values` works idempotently
+- `helm template` produces valid manifests that pass `kubeconform` (or `kubectl apply --dry-run=server`)
+
+---
+
+## 6. Workstream W4 — 50k/s ceiling bench (M10-bench on K8s)
+
+### 6.1 What this proves
+
+The M10-bench in M10_BENCH_VERIFICATION.md proved the **router** ceiling at 50k/s on a single NATS broker with delivery stubbed. W4 re-proves it on the **3-broker cluster** with stubbed delivery, and is the gate for M12.
+
+### 6.2 Test setup
+
+- 2 `loadgen` Deployments (one per company/api-key, same as M11)
+- Each loadgen targets the local K8s ingestd via the `ingestd` Service
+- Combined rate target: 50k/s
+- Duration: 5 min
+- p99 router latency assertion: ≤ 50ms
+- DLQ assertion: = 0
+- Broker queue depth: informational
+
+### 6.3 Exit criteria for W4
+
+- 5/5 green runs at 50k/s on the K8s cluster
+- Router p99 ≤ 50ms in all 5 runs
+- DLQ = 0 throughout
+- NATS cluster resource utilization: each broker < 80% CPU, < 80% memory
+
+---
+
+## 7. Workstream W5 — M11 prod gate on K8s (10k/s × 10 min, full smoke)
+
+### 7.1 What this proves
+
+M11 dev-playground gate passed on parres (4 cores, shared). M12 proves the M11 prod gate on the prod-shape cluster (8+ cores, dedicated). This is the second of the two M12 exit criteria.
+
+### 7.2 Smoke updates needed (carry-over from M11-conditional)
+
+The 2-min re-verification on 2026-06-16 14:48 EDT showed the M11 smoke's rate metric (`ba_ingestd_alerts_received_total`) does not assert on **publish success**. M12 W5 fixes this:
+
+```python
+# scripts/m11_smoke.py — add a publish-success assertion
+def assert_grpc_publish_ok(target_rate, tolerance, window_seconds=30):
+    """Rate of successful NATS publishes, must be ≥ target * (1 - tolerance)."""
+    q = f'rate(ba_ingestd_nats_publish_total{{service="ingestd",result="ok"}}[{window_seconds}s])'
+    rate = float(prom_query(q))
+    if rate < target_rate * (1 - tolerance):
+        fail_(f"NATS publish rate {rate:.0f}/s is below target {target_rate}/s")
+    pass_(f"NATS publish rate {rate:.0f}/s ≥ {target_rate}/s")
+```
+
+(Assumes the `ba_ingestd_nats_publish_total` counter exists in ingestd with a `result` label. If not, it's added as part of W1 — see W1 §3.4.)
+
+### 7.3 Test setup
+
+- 2 `loadgen` Deployments targeting `ingestd:9090` (gRPC)
+- Combined rate target: 10k/s
+- Duration: 10 min
+- p99 ack latency assertion: ≤ 50ms
+- Receive rate assertion: ≥ 9k/s (±10%)
+- **NEW: Publish rate assertion: ≥ 9k/s (±10%)** ← the fix
+- DLQ assertion: = 0
+- Backpressure step: 16 streams × 1k/s = 16k/s, all rate-limited acks honored
+
+### 7.4 Exit criteria for W5
+
+- 3/3 green runs at 10k/s on the K8s cluster
+- 20/20 soak samples ≥ 9k/s receive rate
+- 20/20 soak samples ≥ 9k/s publish rate ← the new assertion
+- p99 ≤ 50ms in all samples
+- DLQ = 0 throughout
+- Backpressure step: 16/16 streams receive rate-limited acks when over per-source cap
+
+---
+
+## 8. Workstream W6 — CI/CD + observability for K8s
+
+### 8.1 CI/CD (GitHub Actions or GitLab CI)
+
+- **Build:** on PR, `docker build` for each service, push to `registry.techno-world.net/lrosales/broad-announce-{service}:<sha>`
+- **Test:** `go test ./...` for each service; smoke against ephemeral docker-compose (existing M10/M11 harnesses)
+- **Deploy:** on merge to main, GitOps (Argo CD) picks up the new image tag and applies via Helm
+
+### 8.2 Observability
+
+- **Prometheus Operator** + `ServiceMonitor` resources for each service
+- **Grafana dashboards** carry over from docker-compose; the PromQL queries are unchanged
+- **Loki** for log aggregation (already in stack)
+- **Alertmanager** rules: NATS broker count, ingestd panic count, deliverd queue depth
+
+### 8.3 Exit criteria for W6
+
+- PR triggers CI build + test
+- Merge to main triggers GitOps deploy to staging
+- Staging smoke (M11 2-min @ 6k) passes automatically
+- Dashboards in Grafana show all 5 service tiers (ingest, broker, route, deliver, archive)
+
+---
+
+## 9. Sequencing & parallelism
+
+```
+W1 ─────────────────┐
+                    ├──▶ W2 (NATS cluster)
+                    │       │
+                    │       ├──▶ W3 (Helm) ──┐
+                    │       │                  │
+                    │       ▼                  ▼
+                    │   W4 (50k/s bench)   W5 (M11 prod gate)
+                    │       │                  │
+                    │       └──────┬───────────┘
+                    │              ▼
+                    │         W6 (CI/CD + observability)
+                    │
+                    (W1 must land first; rest can parallelize with W3 as a fan-out)
+```
+
+Estimated wall time (with one full-time engineer):
+- W1: 2-3 weeks (manifests + stateful services are fiddly)
+- W2: 1 week (JetStream config + chaos test)
+- W3: 1 week (Helm chart extraction)
+- W4: 3 days (smoke adapted from M10-bench)
+- W5: 1 week (smoke update for publish assertion + full 5-step verification)
+- W6: 1 week (CI/CD + observability)
+- **Total: ~6-8 weeks**
+
+---
+
+## 10. What M12 is *not*
+
+(Scope boundaries to prevent creep)
+
+- **Not a rewrite of the data plane.** ingestd, routerd, deliverd-* keep their Go code unchanged. M12 is deployment + NATS topology.
+- **Not a regional/multi-region story.** v2.0 is single-region. Multi-region is v3.
+- **Not an IaC migration.** Terraform / Pulumi for cloud resources is a separate milestone (M13?).
+- **Not a security hardening pass.** mTLS, secrets management, network policies are M11.5 (still pending).
+- **Not a release of the M11 conditional ship issue.** M11 NATS resource limit fix must land in a hotfix before M12 W5 runs. Otherwise the smoke's new publish-success assertion will fail on a single-broker configuration.
+
+---
+
+## 11. Open questions to resolve before W1 starts
+
+1. **Cloud or on-prem K8s?** EKS/GKE/AKS/on-prem (kubeadm, k3s, Rancher)? Determines service-mesh and storage-class choices.
+2. **GitOps tool:** Argo CD, Flux, or plain `kubectl apply` from CI? Argo is the most common; Flux is lighter-weight.
+3. **NATS deployment mode:** official `nats-io/nats` Helm chart, the NATS operator, or custom StatefulSet? Custom gives more control; the operator gives rolling-upgrade + observability for free. **Recommendation:** NATS operator (jetstream + cluster + chaos-tested).
+4. **Stateful storage:** cloud-provider CSI driver (EBS, GCE PD, Azure Disk) or on-prem (NFS, Ceph, Longhorn)? Determines PVC templates.
+5. **TLS / mTLS in K8s:** cert-manager for in-cluster, or pin certs as Secrets? M11.5 will formalize this; M12 just needs to not paint us into a corner.
+6. **Where does the K8s cluster live?** parres is the dev box (docker-compose). Is there a staging K8s cluster already, or do we need to provision one?
+7. **K8s version target:** 1.28+ (covers everything we need). Pin in the chart.
+8. **CI provider:** GitHub Actions, GitLab CI, or Jenkins? Already have a CI somewhere?
+
+---
+
+## 12. Prerequisites (M12 cannot start until these are done)
+
+1. **M11 NATS resource limit issue resolved.** The 2-min re-verification on 2026-06-16 14:48 EDT showed single-broker NATS fails under sustained load. Either:
+   - (a) Tune NATS limits on the single broker to a level that proves the publish path is healthy, OR
+   - (b) Skip ahead to M12 W2 (multi-broker) and use the cluster to clear M11 fully
+   - M12 W5 cannot pass the new publish-success assertion until this is resolved.
+2. **M11 loadgen exit-code issue resolved** (currently deferred). The full 5-step smoke needs a green backpressure step.
+3. **K8s cluster provisioned** (parres can host minikube/k3s for dev; staging cluster for CI; prod cluster for the M12 ceiling bench).
+4. **Container registry** (`registry.techno-world.net/lrosales/...`) ready for CI-built images.
+5. **Push current M11 work to git3** so the team can see the conditional-ship state and the M12 plan.
+
+---
+
+## 13. Definition of done
+
+- [ ] W1: all 14 services have K8s manifests; `kubectl apply -k deploy/k8s/base/` brings them up
+- [ ] W2: 3-broker NATS JetStream cluster, R=3 streams, chaos test passes
+- [ ] W3: Helm chart installs + upgrades cleanly; values per environment
+- [ ] W4: 5/5 green runs at 50k/s, router p99 ≤ 50ms, DLQ=0
+- [ ] W5: 3/3 green runs at 10k/s, 20/20 soak samples green, **publish-success assertion passes**, full 5-step smoke green
+- [ ] W6: CI builds + tests on PR; GitOps deploys on merge; Prometheus + Grafana cover all tiers
+- [ ] SPEC.md M12 row updated to **✅ shipped YYYY-MM-DD**
+- [ ] `M12_VERIFICATION.md` published with bench evidence + chaos test results
+- [ ] `deploy/k8s/README.md` quickstart written
+
+---
+
+## 14. Risks & mitigations
+
+| Risk | Likelihood | Impact | Mitigation |
+|---|---|---|---|
+| NATS operator / Helm chart is immature | Low | Medium (deployment friction) | Fall back to custom StatefulSet (W1 template) |
+| Single-region outage takes down the cluster | Medium | High (revenue) | Multi-AZ nodes; PodDisruptionBudgets; M3 (multi-region) is the long-term fix |
+| Helm values drift between dev / staging / prod | Medium | Medium (config bugs) | Single `values.yaml` with per-env overlays; Kustomize on top if needed |
+| NATS cluster resource limits still hit at 50k/s | Low | High (M12 ceiling fails) | W4 finds the wall early; scale brokers to 5 if needed (R=5) |
+| StatefulSet PVC migration in production | High | High (data loss) | Use Velero for backup; test restore in staging; never delete PVCs without backup |
+| Cost: K8s cluster is more expensive than docker-compose | Certain | Low | Operational benefits outweigh the cost; capacity planning in W3 |
+
+---
+
+**Next step:** resolve open questions (§11) and the M11 NATS prerequisite (#1 in §12). W1 cannot start until both are settled.
+
+---
+
+## Appendix A — Push to git3 (current M11 work)
+
+The local repo on the workspace (`/root/.openclaw/workspace/broad-announce/`) is 5 commits ahead of origin/master on git3, but origin has 6 commits not in local (the M11 hotfixes shipped from a different machine). The histories diverged at `09f0d54`.
+
+To push the M11 work to git3, three approaches:
+
+### A1. Merge origin/master into local, then push (recommended)
+
+```bash
+cd /root/.openclaw/workspace/broad-announce
+git fetch origin master                                   # already done
+git merge origin/master                                   # creates a merge commit; preserves both histories
+# resolve any conflicts (likely in M11 smoke or loadgen code, since both sides edited)
+git push origin master                                    # fast-forward or merge push
+```
+
+Pros: safest, no history rewrite, both sets of commits visible.
+Cons: creates a merge commit; the graph has a "diamond" shape.
+
+### A2. Rebase local onto origin, then force-push
+
+```bash
+cd /root/.openclaw/workspace/broad-announce
+git fetch origin master                                   # already done
+git rebase origin/master                                  # replays 5 local commits on top of origin's 6
+# resolve any conflicts
+git push --force-with-lease origin master                 # rewrites origin's master
+```
+
+Pros: clean linear history.
+Cons: rewrites the commits the user (or their other machine) already pushed. If those commits are referenced anywhere (PRs, CI runs, tags), those references break.
+
+### A3. Drop the local M11 work, fetch origin, and start fresh
+
+```bash
+cd /root/.openclaw/workspace/broad-announce
+git fetch origin master
+git reset --hard origin/master                            # discards local 5 commits
+```
+
+Pros: simple, no conflicts.
+Cons: **loses the local M11 work entirely** (the conditional ship, the loadgen fix, the M11 smoke threshold). Not recommended unless the user is sure the local work is duplicated on origin.
+
+**Recommended: A1 (merge).** The conditional-ship commit (`d8008bc`) is a real change to `SPEC.md` and `M11_VERIFICATION.md` that should be on origin. The loadgen fix (`39907d1`) is also worth shipping. Merging preserves both.
+