# priority-forge-hub — Architecture

How the hub is shaped and **why**. Read this when you want to understand the system; read [`README.md`](./README.md) when you want to use it.

## The two planes

The hub plays two distinct roles on the same backend instance. Adapter classes `implements MemoryHub, OrchestrationHub` because that's pragmatic packaging — nothing requires it. The two interfaces are conceptually independent.

| | **Memory Hub** | **Orchestration Hub** |
|---|---|---|
| **Role** | Shared **context** (passive observation log) | Shared **work** (active control plane) |
| **Stores** | `TeamMemoryEvent` rows (immutable) | `SharedTask` rows (mutable; `current_claim` flips) |
| **Authority over state** | None — records what forges say happened | Yes — arbitrates contested operations |
| **Mutation pattern** | Append-only, no contention | Atomic CAS, contention is the point |
| **Direction** | Push (forges emit automatically) + pull (anyone reads) | Pull (teammates claim from inbox) |
| **Who initiates writes** | Every PF mutation automatically emits | Explicit user action |
| **Consistency** | Eventual is fine | Strong required (else double-work) |
| **Methods on the contract** | 5 (write / read / search / blockersFor / health) | 7 (create / claim / release / renewLease / get / list / getMyClaims) |
| **Schema (pgvector)** | `team_memory_events` table | `shared_tasks` + `agent_identities` tables |
| **Failure mode** | Stale activity feed (low severity) | Wrong agent does the work (high severity) |
| **Mnemonic** | "What happened" | "Who owns this right now" |

The key intuition: **memory plane absorbs work-as-side-effect; orchestration plane requires work-as-decision.** Doing your normal job emits memory events automatically; sharing a task across forges requires you to explicitly publish or claim.

## System architecture

```mermaid
flowchart LR
    %% Alice's forge
    subgraph Alice["Alice's Priority Forge (host)"]
        direction TB
        A_Storage[("Local Tasks<br/>jsonStorage")]
        A_Ranker["Personal ranker<br/>XGBoost · ML · weights"]
        A_MCP["MCP / REST<br/>handler.ts"]
        A_Bridge["src/orchestration/<br/>shared-tasks.ts<br/>(bridge)"]
        A_MCP --> A_Storage
        A_Storage --> A_Ranker
        A_MCP --> A_Bridge
    end

    %% Bob's forge
    subgraph Bob["Bob's Priority Forge (host)"]
        direction TB
        B_Storage[("Local Tasks<br/>jsonStorage")]
        B_Ranker["Personal ranker"]
        B_MCP["MCP / REST"]
        B_Bridge["bridge"]
        B_MCP --> B_Storage
        B_Storage --> B_Ranker
        B_MCP --> B_Bridge
    end

    %% Shared hub
    subgraph Hub["priority-forge-hub (Postgres + pgvector)"]
        direction TB
        subgraph Memory["📜 Memory Plane — shared CONTEXT (passive)"]
            direction TB
            Mem_T[("team_memory_events<br/>append-only · immutable")]
            Mem_Desc["• observations of local activity<br/>• no contention, no arbitration<br/>• 5 methods: write / read /<br/>search / blockersFor / health"]
        end
        subgraph Orch["🔒 Orchestration Plane — shared WORK (active)"]
            direction TB
            Orch_T[("shared_tasks<br/>+ agent_identities<br/>mutable · single-holder lease")]
            Orch_Desc["• atomic CAS via UPDATE...WHERE<br/>• exactly one lease holder at a time<br/>• 7 methods: create / claim / release /<br/>renewLease / get / list / getMyClaims"]
        end
    end

    %% Memory plane flows (dotted = passive emit / observe)
    A_Bridge -. "emits TeamMemoryEvents" .-> Mem_T
    B_Bridge -. "emits events" .-> Mem_T
    Mem_T -. "xmem_get_current_focus<br/>xmem_get_team_activity<br/>xmem_search_team_memory" .-> A_MCP
    Mem_T -. "team-wide reads" .-> B_MCP

    %% Orchestration plane flows (thick = arbitrated mutation)
    A_Bridge ==>|"shared_task_create<br/>shared_task_publish"| Orch_T
    B_Bridge ==>|"shared_task_claim<br/>atomic CAS"| Orch_T
    Orch_T ==>|"on ok: project locally<br/>(sharedTaskId set on row)"| B_Storage

    style Alice fill:#f3e5f5,stroke:#7b1fa2
    style Bob fill:#e8f5e9,stroke:#388e3c
    style Memory fill:#e3f2fd,stroke:#1976d2
    style Orch fill:#fff3e0,stroke:#f57c00
    style Hub fill:#fafafa,stroke:#424242
```

**Reading guide:**

- **Purple / green** = independent forges. No peer-to-peer; everything goes through the hub.
- **Blue** = memory plane (passive log).
- **Orange** = orchestration plane (active arbiter).
- **Dotted arrows** = observation flow (emits, reads — append-only, contention-free).
- **Thick arrows** = arbitrated mutation (atomic CAS — exactly one winner on contention).
- The bridge module (`src/orchestration/shared-tasks.ts`) is the *only* place in PF that imports from `priority-forge-hub`. Everything else uses local storage. This is the load-bearing isolation that makes the hub reusable beyond PF.

## SharedTask lifecycle

The canonical happy path — Alice publishes a local task, Bob picks it up from his inbox, Bob completes it.

```mermaid
sequenceDiagram
    autonumber
    participant A as Alice PF
    participant HM as Hub: Memory
    participant HO as Hub: Orchestration
    participant B as Bob PF

    Note over A: Local task LOCAL-001 "Fix auth"

    A->>HO: shared_task_publish(LOCAL-001)
    HO->>HO: INSERT shared_tasks FOO (claimable)
    HO->>HO: UPDATE...WHERE CAS → currentClaim={alice}
    HO-->>A: SharedTask FOO + ClaimInfo
    A->>A: storage.linkSharedTask(LOCAL-001, FOO)
    A->>HM: emits shared_task_published + shared_task_claimed

    Note over A: Alice works on LOCAL-001<br/>(sharedTaskId=FOO now set)

    A->>HO: shared_task_release(FOO, "handed_back")
    HO->>HO: UPDATE...SET current_claim=NULL, status=claimable
    HO-->>A: ok
    A->>A: completeTask(LOCAL-001, "deferred") — archived
    A->>HM: emits shared_task_released

    Note over B: Bob polls his inbox
    B->>HO: shared_task_list(status:"claimable")
    HO-->>B: [FOO, ...]

    B->>HO: shared_task_claim(FOO, ttl=30min, priority=P1)
    HO->>HO: atomic CAS → currentClaim={bob}
    HO-->>B: SharedTask FOO + ClaimInfo
    B->>B: storage.createTask({task:FOO.title, sharedTaskId:FOO})
    B->>B: storage.claimTask(LOCAL-099, bob) — startedAt captured
    B->>HM: emits shared_task_claimed + task_selected

    Note over B: Bob works on LOCAL-099 normally<br/>His ranker + ML treat it like any task

    loop heartbeat every 15 min (while in_progress)
        B->>HO: renewLease(FOO, bob)
        HO-->>B: ok (lease extended)
    end

    B->>B: complete_task(LOCAL-099, "completed")
    B->>HO: autoReleaseOnLocalComplete (fire-and-forget)
    HO->>HO: UPDATE...SET current_claim=NULL, status=completed
    B->>HM: emits task_completed + shared_task_released

    Note over A,B: Hub state: FOO.status=completed<br/>claimHistory=[alice claimed/handed_back,<br/>bob claimed/completed]
```

**Reading guide:**

- **Solid arrows** = MCP/REST tool calls or storage writes.
- **Dashed arrows** = responses.
- The hub never reaches into PF storage; PF's bridge module materializes / archives projections locally based on hub responses.
- The loop is the PF-side heartbeat (in `src/orchestration/shared-tasks.ts`), not a hub-side sweeper — host policy decides which leases to renew.
- `autoReleaseOnLocalComplete` is the bit that means users only call `complete_task` normally; the hub-side release is automatic.

## What flows through each plane

### Memory plane — typed event log

Only typed events from the `TeamMemoryEventType` union (13 values):

`task_created` · `task_selected` · `task_updated` · `task_completed` · `context_switch` · `project_created` · `project_updated` · `derived_blocker` · `shared_task_published` · `shared_task_claimed` · `shared_task_released` · `shared_task_lease_renewed` · `shared_task_completed`

Each event has a typed `payload` (per `eventType`), a natural-language `summary` (powers semantic search), structured `metadata`, and team + agent + workspace scoping.

**Not** stored here:

| Excluded | Why |
|---|---|
| Task content (priority, effort, notes, body) | Hub-boundary guardrail — those stay PF-local |
| ML training signals (selections, skip decisions, weights, work duration) | Same guardrail — PF's ranker brain stays PF-side |
| Free-form messages / announcements | No `team_announcement` event type — use Slack |
| Documents / attachments / files | Not designed for it; use a different system |
| Generic key-value storage | Only emits typed events; not a kv store |

The plane is **opinionated** about what kinds of events it accepts. Adding a new event type means extending the union; the schema-level guardrail (codified in `types.ts` and root `CLAUDE.md`) prevents PF-content fields from sneaking in.

### Orchestration plane — single-holder lease arbiter

Only one entity type: `SharedTask`. Fields:

- `id`, `teamId`, `title`, `description?` — identity
- `originatorAgentId`, `originatorTeamId`, `createdAt` — provenance
- `currentClaim: ClaimInfo | null` — null when claimable, populated when claimed
- `claimHistory: ClaimHistoryEntry[]` — append-only audit
- `status: 'claimable' | 'claimed' | 'completed' | 'cancelled'`

Same hub-boundary guardrail: SharedTask carries `title` + `description` as seeds for projection, but NO priority/effort/notes/weights/work-duration. Those live in the local PF projection (linked via `sharedTaskId`).

## How information vs work flows (lead-engineer framing)

| You want to ... | Channel | Status |
|---|---|---|
| Hand off specific work to a teammate | Orchestration | Pull-based via inbox today; optional `assignedTo` hint is a Plan 2.5 extension |
| See what teammates are working on | Memory (`xmem_get_current_focus`) | ✓ |
| Audit who has touched a piece of work | Orchestration (`shared_task_history`) | ✓ |
| Coordinate exclusive ownership | Orchestration (atomic claim) | ✓ |
| Broadcast general announcements | — | Not native; use Slack |
| Force a teammate's priorities | — | Not in PF's model by design |

**Lead-engineer mental model:** orchestration is the right plane for top-down task dispatch. PF stays per-engineer-autonomous — the lead's assignment is a *hint*, the receiving engineer's local ranker still scores it against their own queue using their own ML weights. Force-assignment is intentionally not supported; for that, you'd need role-based authority + ranker overrides, which is a different project.

## Backend support matrix

| Adapter | MemoryHub | OrchestrationHub | Notes |
|---|---|---|---|
| `noop` | accept-and-drop | `unsupported_backend` for all methods | default; sync disabled |
| `dryrun` | JSONL append + replay | in-process Map CAS | single-process; tests + dev |
| `xmem` | full (once API deploys) | `unsupported_backend` (TODO) | waits for xtrace.ai backend deploy |
| `pgvector` | full + semantic search | atomic CAS via `UPDATE...WHERE` predicate | reference orchestration substrate |

The pgvector adapter is the **reference implementation**. When the user asks "can we coordinate work across forges?" the answer depends on which backend is enabled.

## Hardening (pgvector only)

Optional per-agent token gate against the "self-declared identity" trust model. Without it, the hub trusts whatever `agentId` each forge declares — anyone with the Postgres connection string could write events as anyone. With it, the pgvector adapter verifies a per-agent SHA-256 token hash against the registered `agent_identities` row before any write / create / claim / release / renewLease.

**Setup:**
1. Generate a 32-byte random token per agent.
2. Run `xmem_register_agent({teamId, agentId, token})` (uses the admin Postgres credential to INSERT the hash).
3. Each agent's PF runs `xmem_enable_sync({backend: 'pgvector', teamId, agentId, agentToken, connectionString})`.
4. From then on, the adapter refuses writes / claims where the configured token doesn't hash-match the registered row.

**Opt-in.** Setups without `agentToken` behave unchanged.

**Reads** (`read` / `search` / `blockersFor` / `get` / `list` / `getMyClaims`) stay unauthenticated — anyone in the team can see what's happening; only consequential mutations are gated.

**What this protects against:**
- ✓ Compromised laptop with only the DB conn string can no longer write as a specific agent
- ✓ Stale ex-team-member with master credentials but no per-agent token can't impersonate
- ✗ Compromised laptop with BOTH DB string and agent token still vulnerable (rotation mitigates)
- ✗ Doesn't apply to xmem (uses org-level credentials via headers — its own hardening path)

For stronger hardening (HMAC-signed events, mTLS), see Plan 3 (not yet written).

## Per-forge boundary

The hub package never imports from PF storage. PF imports from `priority-forge-hub` exactly here:

- `src/mcp/handler.ts` — constructs the integration handle, dispatches MCP tools
- `src/routes/{xmem,shared-tasks}.ts` — REST shims
- `src/orchestration/shared-tasks.ts` — the **bridge** that's the only place calling both `priority-forge-hub` and PF's `storage`. Projection materialization (on claim) and archival (on release) happen there.

This boundary is load-bearing — it's what makes the hub package reusable beyond PF. If you wanted to use `priority-forge-hub` as the substrate for a different host (a Symphony-style executor, a cmux agent runner, etc.), you'd implement your own bridge analogous to PF's. The hub package itself doesn't change.

## Why two interfaces on the same backend instance

Pragmatic, not conceptual:
- They share infrastructure (one Postgres connection pool, one auth path, one network roundtrip).
- A SharedTask lifecycle event (claim / release) ALSO emits an observation event into the memory plane — so orchestration is observable through memory. (Bob calling `xmem_get_team_activity` sees "alice claimed FOO" as an event, in addition to being able to query SharedTask state directly.)
- One config + one connection = simpler deployment.

But the contracts are conceptually distinct. The TypeScript types reflect this — `MemoryHub` and `OrchestrationHub` are separate interfaces, and `supportsOrchestration: boolean` lets hosts probe whether the active backend can arbitrate claims before calling. The decision to package both behind one adapter class is an implementation choice; it could just as easily be two separate classes.
