# 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.

## Team hub configuration (`~/.priority-forge/team-hub.json`)

Three explicit opt-in planes (defaults off):

| Plane | Key | Enables |
|-------|-----|---------|
| Pulse | `pulse.emit` | `TeamPulseSyncEmitter` → `sync_event` bus |
| Memory | `memory.enabled` + `memory.backend` | Subscriber → `MemoryHub` (noop / dryrun / pgvector / xmem) |
| Orchestration | `orchestration.enabled` | `shared_task_*` (requires pgvector or dryrun) |

Configure via **`hub_configure`** MCP/REST (aliases: `xmem_enable_sync`, `enable_team_sync` for pulse only). Enabling memory with a real backend auto-sets `pulse.emit: true`. Legacy `xmem.json` + `team-pulse.json` migrate once on first load.

**Salience** (`salience.decayTauDays: 14` by default, aligned with PF `interactionWindowDays`): MVP1 ranks search/read by `semanticScore × exp(-age/τ)`; MVP2 adds access refresh (`xmem_touch_memory`) and tombstone candidates (`xmem_list_decay_candidates`, `xmem_archive_memory`). XTrace-native delete remains stubbed on the `xmem` backend.

## System architecture

**Mental model:** Alice and Bob each run a full Priority Forge on their own machine (purple / green). The grey box in the center is **one shared hub** for the whole team — the only place that arbitrates shared-task claims. Each PF also has a small **hub bridge** node (`shared-tasks.ts` under `src/orchestration/` — a misleading folder name; it is a *client*, not a second orchestrator).

```mermaid
%%{init: {'theme': 'dark', 'themeVariables': {'background': '#121212', 'primaryTextColor': '#f5f5f5', 'lineColor': '#b0bec5'}}}%%
flowchart LR
    subgraph Alice["Alice's Priority Forge"]
        direction TB
        A_Storage[("Local Tasks")]
        A_Ranker["Personal ranker"]
        A_MCP["MCP / REST"]
        A_Bridge["Hub bridge client<br/>shared-tasks.ts"]
        A_MCP --> A_Storage
        A_Storage --> A_Ranker
        A_MCP --> A_Bridge
    end

    subgraph Bob["Bob's Priority Forge"]
        direction TB
        B_Storage[("Local Tasks")]
        B_Ranker["Personal ranker"]
        B_MCP["MCP / REST"]
        B_Bridge["Hub bridge client<br/>shared-tasks.ts"]
        B_MCP --> B_Storage
        B_Storage --> B_Ranker
        B_MCP --> B_Bridge
    end

    subgraph Hub["SHARED hub — priority-forge-hub"]
        direction TB
        subgraph Memory["Memory plane"]
            Mem_T[("team_memory_events")]
        end
        subgraph Orch["Orchestration plane"]
            Orch_T[("shared_tasks")]
        end
    end

    A_Bridge -. emits events .-> Mem_T
    B_Bridge -. emits events .-> Mem_T
    Mem_T -. team reads .-> A_MCP
    Mem_T -. team reads .-> B_MCP

    A_Bridge ==>|publish| Orch_T
    B_Bridge ==>|claim / release| Orch_T
    Orch_T ==>|project locally| B_Storage

    style Alice fill:#3d2450,stroke:#ce93d8,color:#f3e5f5
    style Bob fill:#1e3d24,stroke:#81c784,color:#e8f5e9
    style Memory fill:#0f2d4a,stroke:#64b5f6,color:#e3f2fd
    style Orch fill:#4a2f0f,stroke:#ffb74d,color:#fff8e1
    style Hub fill:#2a2a2a,stroke:#e0e0e0,color:#eeeeee
```

**Reading guide:**

- **Purple / green** = two separate PF installs (Alice’s laptop, Bob’s). Each has its own local tasks, ranker, and ML. No peer-to-peer between them.
- **Hub bridge client** (in each forge) = thin adapter to the shared hub. Lives in `src/orchestration/shared-tasks.ts` but does **not** own orchestration — it only calls hub APIs.
- **Grey center — SHARED hub** = one Postgres instance for the team. This is the **only** orchestration authority (`shared_tasks`, atomic claim/release).
- **Blue — Memory plane** = append-only team activity log (observations, no arbitration). Methods: write, read, search, blockersFor, health.
- **Orange — Orchestration plane** = shared-work arbiter (single lease holder per task). Methods: create, claim, release, renewLease, get, list, getMyClaims.
- **Dotted arrows** = passive observation (emit / read).
- **Thick arrows** = hub-arbitrated mutation (publish, claim) or local projection after a successful claim.

## SharedTask lifecycle

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

```mermaid
%%{init: {'theme': 'dark', 'themeVariables': {'background': '#121212', 'actorBkg': '#2a2a2a', 'actorBorder': '#bdbdbd', 'actorTextColor': '#f5f5f5', 'signalColor': '#b0bec5', 'noteBkgColor': '#1e3a5f', 'noteTextColor': '#e3f2fd', 'noteBorderColor': '#64b5f6'}}}%%
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 (14 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` · `shared_task_cancelled`

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
- `deadline?`, `priority?` — advisory projection seeds (V4.4)
- `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`, `deadline`, and an advisory `priority` as **seeds for projection** — but NO effort, notes body, weights, work-duration, priority *scores*, or priority *change history*. `deadline` and `priority` are advisory: on claim the claimer's forge adopts them as base values, then recomputes time-sensitivity + heuristic weights locally and may override the priority. Everything rank- or ML-affecting lives 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 |
| Steer a teammate's priority on shared work | Orchestration (advisory `priority` seed at publish) | ✓ as a hint — the claimer adopts it as the base label, then re-ranks locally (V4.4) |
| Re-prioritize a teammate's already-claimed shared task | Orchestration (`shared_task_set_priority` directive) | ✓ in place — owner's forge applies it via a normal priority change, no id churn (V4.4) |
| 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) | ✓ |
| Spot duplicate / overlapping work across the team | Orchestration (`shared_task_search`) + Memory (`xmem_search_team_memory`) | ✓ (V4.4) |
| Retire a duplicate unclaimed shared task | Orchestration (`shared_task_cancel`, originator-only) | ✓ (V4.4) |
| 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. The V4.4 advisory `priority` seed makes this concrete: a lead can publish/create a SharedTask at `P0`, and the claimer's forge starts it at `P0` but then refines the exact ordering with local heuristics (their deadlines, what it blocks for them). *Force*-assignment — overriding a teammate's queue without their forge re-ranking — is intentionally not supported; for that you'd need role-based authority + ranker overrides, a different project.

## Backend support matrix

| Adapter | MemoryHub | OrchestrationHub | Notes |
|---|---|---|---|
| `noop` | writes return `accepted: false` | `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.

## Projection seeds & cross-forge ML continuity (V4.4)

Two flows were added so a SharedTask can carry just enough to seed a useful local projection, while the per-forge ML stays joinable across owners.

**1. Advisory projection seeds (publish → hub → claim).** A SharedTask now freezes `title`, `description`, `deadline`, and an advisory `priority` at publish. On claim, the claimer's forge *adopts* the seeds — priority becomes the base label via the fallback `opts.priority ?? sharedTask.priority ?? DEFAULT_PRIORITY` — then re-ranks locally: heuristic weights recompute against the claimer's own queue and `timeSensitivity` recomputes from `deadline`. The lead steers; the forge refines. Effort, weights, work-durations, priority *scores*, and priority *change history* never travel.

**2. Cross-forge ML continuity (the id-churn fix).** A reclaim mints a fresh local id, so without a stable key every prior ML record (completion, selection, priority-change, skip-decision, drag, rebalance) would orphan. Each record now carries the projection's `sharedTaskId`, and `exportTrainingData` indexes live tasks by it and falls back to that join when the local-id lookup misses. So training sees one logical work-item across the whole ownership chain instead of disconnected fragments.

**3. In-place priority directives (zero-churn reprioritization).** To re-prioritize a task that *stays with its owner*, a lead calls `shared_task_set_priority` — which emits an advisory `priority_directive` memory event, mutating nobody's queue. The owning forge surfaces pending directives (`shared_task_get_priority_directives`) and applies them in place (`shared_task_apply_priority_directives`) via a normal `updateTask` priority change — a useful override signal, no destroy/recreate, no new local id. It's agent-mediated (the owner's forge applies; the hub never force-writes a teammate's queue) and idempotent in practice (once applied, a directive reads as `satisfied` and is skipped). This is the reframe of "empty everyone's queue and hand it back": only genuine reassignments round-trip; everything else is read-to-dedupe + re-prioritize-in-place.

```mermaid
flowchart TB
    subgraph Alice["Alice's forge — publisher"]
        direction TB
        A_task["Local task LOCAL-001<br/>title · notes · deadline · priority=P1"]
        A_ml[("Alice ML records<br/>completion / selection / …<br/>sharedTaskId=FOO stamped")]
        A_task -->|"work emits"| A_ml
    end

    subgraph Hub["SHARED hub — shared_tasks"]
        direction TB
        ST["SharedTask FOO<br/>SEEDS: title · description · deadline · priority (advisory)<br/>NOT carried: effort · weights · work-time · priority deltas/scores"]
    end

    subgraph Bob["Bob's forge — claimer"]
        direction TB
        B_proj["Projection LOCAL-099 (fresh local id)<br/>sharedTaskId=FOO<br/>priority = seed P1 → re-ranked locally<br/>weights recomputed vs Bob's queue"]
        B_ml[("Bob ML records<br/>sharedTaskId=FOO stamped")]
        B_proj -->|"work emits"| B_ml
    end

    A_task ==>|"publish: freeze seeds"| ST
    ST ==>|"claim: project + adopt seeds"| B_proj

    A_ml -. "join on sharedTaskId=FOO" .-> Train
    B_ml -. "join on sharedTaskId=FOO" .-> Train
    Train[["exportTrainingData<br/>one logical work-item across owners —<br/>survives the LOCAL-001 → LOCAL-099 id churn"]]

    style Alice fill:#3d2450,stroke:#ce93d8,color:#f3e5f5
    style Bob fill:#1e3d24,stroke:#81c784,color:#e8f5e9
    style Hub fill:#4a2f0f,stroke:#ffb74d,color:#fff8e1
    style Train fill:#0f2d4a,stroke:#64b5f6,color:#e3f2fd
```

**Reading guide:**

- **Thick arrows** = hub-arbitrated transfer of *seeds only* (publish freezes them; claim adopts them). Priority crosses as an advisory base label, not an authoritative score.
- **Solid thin arrows** = normal local work emitting ML records inside each forge.
- **Dotted arrows** = a training-time join, not a runtime call: `sharedTaskId` is the stable key that stitches Alice's and Bob's records into one work-item even though their local ids differ.
- The boundary still holds: the hub carries seeds for projection, never the forge-local ranking/ML signals. `sharedTaskId` lives *on PF's ML records* (PF-side), not as new hub content.
