Stage 3 — FORGE: Pebble V0.8 Architecture
> The creative architecture distilled from Stages 1+2. > Audience: meta:omega (executes this) and meta:hydra (stress-tests this).
Full Public Reader
Stage 3 — FORGE: Pebble V0.8 Architecture
> The creative architecture distilled from Stages 1+2.
> Audience: meta:omega (executes this) and meta:hydra (stress-tests this).
---
Core formula
Two planes. One projection. One bridge. Three switching strategies.
Pebble V0.8 ≡ ControlPlane(HTTP, must-succeed-or-fail-loudly)
⊕ DataPlane(WebSocket + Polling, lossy-tolerant, projection-backed)
⊕ CaptainBridge(mac5: tmux-as-RPC, [CTRL]/[DATA]/[STATE]/[COMPACT])
⊕ ChatStrategyLadder(AX → Shortcut → URLScheme, telemetered)The whole V0.8 system is the result of mechanically applying that formula to each of the four phases. There is exactly one creative gesture per phase — every other decision falls out of the formula.
---
Category system (DNA)
Every V0.8 artefact is one of six categories. Each category has fixed behavioural rules. Mixing categories within a single endpoint is forbidden.
| DNA | Plane | Lifetime | Failure rule | Storage | Examples |
|---|---|---|---|---|---|
| 🟢 ControlOp | Control | request/response | throws + user-visible banner | none | `/codex/inject?chat_id`, `/captain/ask`, `/state/restore` |
| 🔵 DataStream | Data | long-lived WS / poll loop | silent retry, banner only when stale > N min | App Group projection | ntfy WS, ntfy `.summarised`, snapshot polling |
| 🟡 Bridge | tmux-as-RPC (Captain) | long-lived daemon | restart via launchd; control ops 504 on timeout; data ops drop messages | none (bridge is stateless) | `captain-bridge.py` |
| 🟠 Snapshotter | Local→disk | long-lived launchd | KeepAlive + atomic-rename on writes; missing snapshots → "Captain unsure" | `[home-path]` | `mesh-state-snapshotter.py` |
| 🟣 Projection | Local SQLite | persistent | append-only mirror of authoritative SQLite; widget read-only | `Group/pebble-projection.sqlite` | conversations_top3, projection_captain, projection_health |
| 🔴 Strategy | Code-level | per-call | confidence-scored ladder; telemetry to caller | none | `AXStrategy`, `ShortcutStrategy`, `URLStrategy` |
A new endpoint or daemon must declare its DNA in its docstring. PR review rejects mixed-DNA artefacts (e.g., a `ControlOp` that returns a stream).
---
Vocabulary / palette
The names this system uses, locked. Synonyms get rejected in code review.
intent = the user's typed/spoken/shared text, before routing
prompt = the text that ultimately reaches an agent (after Captain rewrite)
chat = a Codex.app sidebar entry (not "thread" — Codex's UI says "chat")
session = a Claude Code .jsonl on disk (not "chat", not "thread")
pane = a tmux pane (mac1-tty…, mac4-tty…)
conversation = a Pebble UI row (1:1 with Conversation.id; routes to one of the above)
manifest = a single mesh-state-snapshotter output (.json)
summary = Captain-produced ≤80-char condensation of an ntfy event
ref_event_id = ntfy event id this summary was produced from
state-tick = captain-bridge's 60s mesh state inject into Captain
compaction = Captain's 12h-or-800K-tokens self-summarisation
strategy = one rung of the chat-switching ladder (ax/shortcut/url)
projection = the App Group SQLite mirror that the widget reads
plane = control or data; never both within an artefact
DNA = one of the six categories aboveForbidden synonyms (PR review will flag):
- "thread" for a Codex chat
- "chat" for a Claude Code session
- "summary" for a Captain control-plane answer (call that an `answer`)
- "feed" for the data plane (use "stream" or "ntfy")
- "broker" for Captain (Captain is a service on both planes, not the bus)
---
Two-plane protocol contract
Every iOS-side networking call lives behind one of two protocols. No exceptions.
protocol ControlOperation: Sendable {
associatedtype Response: Codable
func perform(via client: MeshClient) async throws -> Response // throws on failure
}
protocol DataStream: Sendable {
func subscribe(into projection: AppGroupProjection) async // never throws
}Rule. A new V0.8 endpoint must implement exactly one of these. A "control op that returns a stream" is decomposed into `ControlOperation` + `DataStream` pair.
Concrete control ops shipped in V0.8:
- `InjectToCodexChat(chat_id:)` → `InjectResponse`
- `AskCaptain(question:)` → `CaptainAnswer`
- `RouteThroughCaptain(intent:autopilot:)` → `RouteDecision`
- `RestoreMachine(target:scope:)` → `RestoreTaskID`
- `FetchManifest()` → `MeshManifest`
- `SnapshotNow()` → `MeshManifest`
- `FetchCodexChats()` → `[CodexChat]` (already partially shipped)
Concrete data streams shipped in V0.8:
- `NtfyRawStream` (already shipped in P1.A)
- `NtfySummarisedStream` (NEW — same NtfySubscriber, additional topic)
- `ManifestPollStream` (NEW — every 30s GET `/state` if reachable)
- `GatewayHealthProbeStream` (NEW — every 30s, writes `projection_health`)
---
Captain bridge as language
Captain is a function exposed via tmux-as-RPC. Inputs are prefixed JSON; outputs are one-line JSON. The prefix IS the dispatch.
INJECT → Captain → RESPONSE
─────────────────────────────────────────────────────────────────
[CTRL] {"q":"where are we?"}
{"answer":"5-line status"}
[DATA] {"event_id":"abc","title":"Codex queued","raw":"..."}
{"summary":"queued lume-pcloud build","severity":"info"}
[STATE] <2KB packed mesh state>
{"acknowledged":true,"flag":null}
(or {"acknowledged":true,"flag":"codex_dead"} if anomaly)
[COMPACT] Summarise the last 720 state ticks into ≤2KB.
{"compaction":"..."}
[CONTEXT-RESET] <prior compaction>
{"acknowledged":true}This is the ONLY language Captain speaks for V0.8. Free-form chat with the human still works (the user can type in the captain pane), but bridge-issued messages MUST use one of these five prefixes. The bridge ignores Captain output that does not start with a known prefix's expected JSON shape.
This is what makes Captain composable: every consumer of Captain (the iOS quick-action, the autopilot endpoint, the snapshotter's anomaly observer, future widgets) speaks the same language.
---
Strategy ladder (P2 critical primitive)
┌──────────────────────────┐
intent ───→ │ AXStrategy.try_switch() │ conf 0.95 (exact) | 0.7 (fuzzy)
└──────────────────────────┘
│ on fail
▼
┌──────────────────────────┐
│ ShortcutStrategy.try_* │ conf 0.85 (cache hit)
└──────────────────────────┘
│ on fail
▼
┌──────────────────────────┐
│ URLStrategy.try_switch │ conf 0.30
└──────────────────────────┘
│ on fail
▼
503 chat_unreachable → Pebble banner
"Send to active chat instead?"Each strategy returns `(success: bool, confidence: float, evidence: dict)`. Telemetry flows back to `/codex/inject` callers, into Pebble's diagnostics view. After 100 trials, we have empirical confidence numbers, not guesses.
The ladder is the V0.8 anti-fragility primitive. Every place we have to make a fragile choice (chat switching, gateway routing, fallback summarisation), the answer is not "pick the best one" but "build a confidence-scored ladder." See "anti-patterns" below.
---
Projection model (the App Group SQLite)
`Group/pebble-projection.sqlite` schema (read-only from widget; read-write only from main app):
-- conversations_top3: latest 3 conversations + their last message preview
CREATE TABLE conversations_top3 (
rank INTEGER PRIMARY KEY, -- 1, 2, 3
conversation_id TEXT,
agent TEXT, -- "Codex chat 'Wave 9'"
machine TEXT, -- "mac4"
last_message_text TEXT, -- truncated to 120 chars
last_active INTEGER -- epoch
);
-- projection_captain: Captain's last summary (ring of 1)
CREATE TABLE projection_captain (
id INTEGER PRIMARY KEY CHECK (id=1),
summary TEXT,
severity TEXT, -- info|warn|err
ref_event_id TEXT,
produced_at INTEGER
);
-- projection_health: gateway up/down (ring of 5)
CREATE TABLE projection_health (
machine TEXT PRIMARY KEY, -- mac1, mac4, mac5, ntfy, captain
status TEXT, -- up|down
last_seen INTEGER, -- epoch
latency_ms INTEGER -- last successful probe
);Write rules. Every mutation in the main `pebble.sqlite` triggers a projection update:
- `appendMessage` → recompute `conversations_top3` (cheap; one SQL with ORDER BY).
- `NtfySubscriber` event on `.summarised` → upsert `projection_captain`.
- `GatewayHealthProbe` 30s → upsert `projection_health`.
Read rules. Widget opens projection in WAL mode with `busy_timeout=2000ms`. Main app must never lock the projection longer than 100ms.
---
Anti-patterns (these will be rejected in code review)
1. 🚫 Mixed-plane endpoint. "It's a control op that streams updates" — split into a control op + a data stream.
2. 🚫 Singular-strategy chat switch. "AX is good enough." — always ship the full ladder + telemetry.
3. 🚫 Pebble polling Captain directly. Captain is reached only through aura-gateway. The bridge is the only bridge.
4. 🚫 Projection as source of truth. Projection is always derived from `pebble.sqlite` + ntfy. Direct widget writes are forbidden.
5. 🚫 Free-form Captain output for control ops. Every `[CTRL]` reply is one-line JSON or it's a 504.
6. 🚫 New SQLite schema in `Persistence`. All P1 schema is locked. New tables go in the projection or in a clearly-separate file.
7. 🚫 Synchronous wait for Captain in autopilot. Always `task_id` + Pebble polls; UI never blocks > 1.5s.
8. 🚫 ntfy as control-plane. ntfy is data plane. If Pebble needs an answer, it goes to aura-gateway.
9. 🚫 Widget calling MeshClient. Widget reads only the App Group projection; main app pushes data in.
10. 🚫 "Future work" deferral. Every wave ships fully or kills its phase per the gate criteria.
---
Worked examples
Example 1 — User taps "Where are we?" on iPhone
DNA chain: 🟢 ControlOp(AskCaptain) → 🟡 Bridge[CTRL] → 🟢 ControlOp.Response
Plane: Control end-to-end. UI blocks ≤8s with spinner. 504 → local heuristic fallback.
Telemetry: captain_lag_ms reported back; Pebble shows "Mesh status — 12s ago."Example 2 — Codex completes a chat task
DNA chain: Codex.app stdout → ntfy raw publish (existing)
→ 🔵 NtfyRawStream(captain-bridge subscribed) → 🟡 Bridge[DATA]
→ Captain summary → ntfy `.summarised` publish
→ 🔵 NtfySummarisedStream(Pebble subscribed)
→ 🟣 Projection(projection_captain upsert)
→ 🟣 Projection(conversations_top3 last_message_text update)
→ WidgetCenter.reloadAllTimelines
Plane: Data end-to-end. No banners on transient failure. Stale > 2 min → "Captain offline" banner.
Provenance: ref_event_id traced from raw ntfy event id to summary; Pebble shows summary inline with raw event.Example 3 — User sends "fix the build" with autopilot ON
DNA chain: 🟢 ControlOp(RouteThroughCaptain[intent="fix the build", autopilot=true])
→ 🟡 Bridge[CTRL] → Captain decides target_conversation_id + rewritten_prompt
→ 🟢 RouteDecision returned ≤8s
→ Pebble shows 1.5s confirmation banner (swipe to override)
→ 🟢 ControlOp(InjectToCodexChat[chat_id]) using normal MeshClient send
→ 🔴 StrategyLadder switches Codex chat (AX → Shortcut → URL)
→ 🟢 InjectResponse with strategy_used=ax
→ Pebble persists routing decision as system message in BOTH source + dest
Plane: Control plane (the routing decision is must-succeed). Data-plane echoes via ntfy as side effect.
Failure: Captain offline → 504 → autopilot soft-disabled with banner; user re-sends manually. No absorbing state.Example 4 — mac4 codex-gateway crashes
DNA chain: 🔵 GatewayHealthProbeStream sees 30s timeout
→ 🟣 Projection(projection_health.mac4.status='down')
→ Pebble subscribes via @Observable → MeshHealth shows red dot
→ ChatView for any mac4-routed conversation shows banner "mac4 Codex unreachable"
→ User taps [Restore] in banner OR MeshHealth
→ 🟢 ControlOp(RestoreMachine[target=mac4, scope=codex])
→ aura-gateway runs restore.py with chat_id navigation
→ Snapshot 5min later confirms mac4 back; banners auto-clear
Plane: Data plane detects (silent), Control plane recovers (loud).---
Why this architecture (the rationale, two paragraphs)
The whole V0.8 architecture answers one question: "What does it mean to leave the laptop at home?" Not "how do I send a prompt from my phone" — that ships in V0.7. The deeper answer requires the mesh to keep running when the phone is asleep, requires something to summarise the firehose into glanceable updates, requires the phone to know when the mesh is degraded, and requires the phone to recover it without a laptop. The two-plane split makes the difference between "command" and "ambient" explicit. The projection makes the widget cheap. The bridge makes Captain into a language, not a chat partner. The strategy ladder makes Codex chat-switching anti-fragile.
The cost is four new artefacts (captain-bridge, snapshotter, widget, share extension) and four new gateway endpoints (`/captain/ask`, `/captain/route`, `/state/*`, `/codex/chats` extension). That's the smallest set of moving parts that satisfies the leisure-vision goal. Anything smaller fails one of the requirements; anything larger violates EW Bounded Divergence (≤4 tracks per phase). This is the binding constraint, and it's tight on purpose.
---
What this hands to Stage 4 (RAIL)
1. Phase boundaries are P2 → P3a → P3b → P3c → P4 → P5. Order is forced by dependency: chat_id from P2 propagates to P3 (autopilot) and P4 (restore manifest); state ticks from P3 are an input to autopilot; manifest from P4 is an input to widget projection (P5).
2. Each phase has at most 4 parallel tracks (named A through F by the Stage 2 plan, but no phase activates more than 4 simultaneously).
3. EW invariants are pre-verified at the architecture level (Stage 2 §8). Stage 4's job is to verify them at the gate level — i.e., after each phase completes, are entropy, divergence, cross-layer forcing, and viable next steps all satisfied?
4. Pulse auto-spawn opportunities: Wave 5 (snapshotter daemon), Wave 6 (App Group projection), Wave 7 (widget + share). These are largely closed-form Pulse-able; AX archeology and Captain bootstrap require human attention.
End of Stage 3.
Promotion Decision
Promote into a technical note or architecture paper with implementation anchors.
Source Anchor
crucible-output/pebble-v08-p2-p5/03-architecture.md
Detected Structure
Method · Evaluation · Code Anchors · Architecture · is Stage Research