diff --git a/specs/ADK-FLAIR-ADAPTER.md b/specs/ADK-FLAIR-ADAPTER.md new file mode 100644 index 0000000..d8d23f1 --- /dev/null +++ b/specs/ADK-FLAIR-ADAPTER.md @@ -0,0 +1,191 @@ +# adk-flair — Flair as the memory backend for Google ADK (ops-gbsy) + +**Status: DESIGN APPROVED — Kern APPROVE (verdict held through compound-tag pivot), Sherlock APPROVE on re-review (STOP lifted), 2026-08-05 17:40Z.** Cleared for implementation; repo creation awaits PAT rotation. +**Bead:** ops-gbsy · **Owner:** Flint (spec) · implementer TBD after design review +**Research base:** google/adk-python v2.6.2 (clone-verified 2026-08-05), OpenMemory precedent (adk-python#3387 → community#20), Memory Bank ADK quickstart. + +## Why (positioning, one paragraph) + +Vertex AI Memory Bank is the managed `memory.load(userId)` — the assumption we +position against. ADK's memory layer is a **designed third-party seam** +(`BaseMemoryService` + a documented `services.py`/`services.yaml` scheme +registry), and Google's maintainers have ruled that non-Google backends live +outside core (OpenMemory's core PR was closed with "move to community repo"). +An `adk-flair` package makes the pitch concrete: run your agent on Google's +stack, keep your memory yours — self-hosted, federated, portable to your +non-ADK agents. Their consolidation runs server-side in Memory Bank; ours runs +in the user's own memory (REM nightly). Consolidation belongs to the memory, +not the vendor. + +## Scenarios (design targets) + +1. **Quickstart parity:** a dev on the Memory Bank ADK quickstart swaps ONLY + step 2 (provision) and step 6 (`FlairMemoryService(...)` instead of + `VertexAiMemoryBankService(...)`). Steps 3–5, 7 unchanged. Cross-session + recall works on the second session. +2. **CLI/dev-UI path:** `adk web --memory_service_uri="flair://localhost:9926"` + works via the documented registry (`services.py` registering the `flair://` + scheme). The dev UI's `PATCH .../memory` ingestion endpoint works unchanged. +3. **Flair unreachable:** ADK swallows `search_memory` exceptions on the + every-turn path (PreloadMemoryTool) — failure is *silent recall loss by + ADK's design*. The adapter must therefore fail FAST (connect/read timeout + ~2s total, one attempt, no retry storm on the turn path) and log one + structured warning naming host + remedy. A hung Flair must never add + seconds to every turn. +4. **Portability proof (the demo):** a memory written via an ADK session is + readable outside ADK with no export step — the flair CLI authenticating + **as the same app principal** (`FLAIR_AGENT_ID=`) searches and + finds it. (Kern's catch: with `private` visibility, cross-AGENT reads + don't happen — so the demo is honestly framed as "your app's memory, + inspectable by you from any tool," same principal, not cross-agent magic. + True cross-agent sharing is Flair visibility/org semantics — phase 2.) +5. **Eval:** `LocalEvalService(memory_service=FlairMemoryService(...))` runs + `adk eval` unchanged (ADK's eval harness accepts an injected service). + +## Design + +**Package:** `packages/adk-flair` in the flair monorepo — the established home +of every adapter (langgraph-flair, pi-flair, n8n-nodes-flair, hermes-flair, +openclaw-flair all live there; langgraph-flair is the Python/pip precedent). +Published to PyPI as `adk-flair`. Depends on `google-adk` + `httpx`. No new +repo (corrected 2026-08-05 — Nathan's catch; the standalone-repo idea was +OpenMemory's shape, not ours). Phase 2 (separate decision): PR the same class +into `google/adk-python-community` for discoverability — never a fork, never +a core PR (maintainer policy, verified). + +**Class:** `FlairMemoryService(BaseMemoryService)` implementing all four +methods (two required + two optional): + +| ADK call | Flair mapping | +|---|---| +| `add_session_to_memory(session)` | batch-write session events (filter no-text events, as Vertex impl does) | +| `add_events_to_memory(app_name, user_id, events, …)` | incremental per-turn writes (the quickstart's `after_agent_callback` path) | +| `add_memory(app_name, user_id, memories, …)` | direct `POST /Memory` writes | +| `search_memory(app_name, user_id, query)` | Flair semantic search; map hits → `MemoryEntry(content=text, timestamp=ISO 8601, author=record author)` | + +Empty result list is valid and common — return it, never raise, on 0 hits. +No consolidation logic in the adapter: Flair REM owns it (positioning point, +and it keeps the adapter ~small). + +**Scope mapping (K&S both re-derived; verdict: right trade, wrong mechanism as +first drafted — fixed here):** +The ADK contract scopes everything by `{app_name, user_id}`. Flair's model is +agentId-keyed. The ADK app authenticates as ONE Flair agent (its service +identity); per-user principals rejected by both reviewers (key sprawl at user +cardinality — the agent table is an org directory, not a user database; no +offline story; and it's the same trust-boundary choice Vertex Memory Bank +itself makes: one agent_engine_id, user_id as scoping data). + +**Mechanism (Sherlock's STOP, resolved):** Flair's Memory schema has NO +`app_name`/`user_id` fields, and search filters exist for **tags and subject +only** — so "structured metadata filtering" as first written required server +changes this spec forbids. Adopted: a **single compound tag** — +`adk::` on every record, filtered on every search. +Compound, not two tags, for two verified reasons: `POST /SemanticSearch` +accepts exactly ONE `tag` string, and a per-user compound tag is selective by +construction, which matters below. Rules that make it defensible: +- `user_id` is **mandatory** in the search path — missing/empty ⇒ return + empty, never search unscoped (Kern). It comes from ADK's session context, + never from caller-supplied input (Sherlock's forgeability question: + framework-derived, and the adapter exposes no way to override it). +- **Verification gate: CLOSED (source-read of Flair + pinned harper 5.1.22).** + Flair never tag-filters in JS after ranking — the tag rides inside the + engine query on every retrieval leg. But Harper's cost planner decides the + driver per query: a **selective** tag wins the planner race, drives the + index seek, and yields exact cosine ordering over exactly the tag-matching + set (true pre-filter, exhaustive). A **non-selective** tag loses to the + HNSW pseudo-condition and degrades to engine-level post-filtering over a + global ≤512-candidate set — other users' records transit that in-memory + candidate set before the condition drops them, and result starvation is + possible (hybrid's BM25 leg, which pre-filters exhaustively, mitigates). + Consequences adopted: (1) the compound per-user tag keeps multi-user + corpora in the selective regime; (2) **the adapter re-verifies the + compound tag on EVERY hit before mapping it out** — the client-side + analogue of Flair's own `isAllowed` defense-in-depth, killing the + one-bug-away class at the adapter boundary for ~zero cost; (3) the + integration test asserts via Harper's `explain` support that the tag + drives the plan on a representative multi-user corpus — the check can + fire, not just a comment. Note: MCP `memory_search` exposes no tag + parameter at all — the adapter speaks REST `POST /SemanticSearch` + directly, never the MCP tool. +- README Security section, verbatim commitment: "All users of one ADK app + share one Flair principal. Per-user isolation is enforced by tag-based + server-side filtering, not cryptographic key separation. A bug in that + filter would leak cross-user memories. For key-level isolation, use + per-org Flair principals (the org layer)." + +**Auth & config:** env-first, no secrets in code or YAML: +`FLAIR_URL` (or the `flair://host:port` URI), `FLAIR_AGENT_ID`, +`FLAIR_KEYFILE` (path to Ed25519 key; value never read into config, never +logged). Constructor contract (Sherlock): missing config ⇒ ValueError naming +the VARIABLE; keyfile present but invalid ⇒ **parse and validate the key +material in the ctor** and raise — deferring the parse to first use lands the +failure inside ADK's exception-swallowing path as permanent silent empty +recall. Error messages name variables, never filesystem paths. + +**Wrong-URL protection (Sherlock: "loud README language is documentation, +not a control"):** a typo'd `FLAIR_URL` ships every user query to a stranger, +every turn, silently. Controls, safe-path-easy-path ordered: (1) localhost / +127.0.0.1 / ::1 targets construct freely — the common self-hosted case has +zero friction; (2) any NON-local host refuses to construct unless +`FLAIR_ALLOW_REMOTE_URL=1` is set, the error naming the exact URL it refused +— pointing at remote infra becomes a deliberate act (this replaces Sherlock's +confirm-everything interlock: same protection, no tax on the default case); +(3) the resolved URL is logged once at WARNING on first request either way. +README warning stays, as backup. + +**Timeouts & failure visibility (Kern + Sherlock, merged):** search path +budget 2s TOTAL covering the full lifecycle including DNS — +`httpx.Timeout(connect=0.5, read=1.5, write=1.0, pool=0.5)`; one attempt, no +retry on the turn path. The swallowed-exception warning includes host, +elapsed_ms, and which phase died (connect vs read). **Write paths too** +(Sherlock): `add_session_to_memory`/`add_events_to_memory` failures log a +structured warning (session id, event count, HTTP status) — silently lost +memories are the write-side twin of silent recall loss. + +**Idempotent writes (Kern, both replies converged):** deterministic record id +`${app_name}:${user_id}:${session_id}:${event.id}` — re-ingestion upserts the +same record, statelessly. REM consolidates content; it never sees duplicates. +**`custom_metadata`:** never silently dropped — unsupported keys log +"custom_metadata ignored by adk-flair" once per session (a user setting +TTL must not believe it worked). + +**MemoryEntry mapping (Kern):** `MemoryEntry.content` is +`google.genai.types.Content`, not a string — the adapter constructs +`types.Content(parts=[types.Part(text=)])` per hit, with +`author` and ISO `timestamp` carried from the record. Spelled out here so the +implementer doesn't discover it at the type checker. + +**Verification (before done):** +- Unit tests mirroring ADK's own patterns (mock backend; assert scope + propagation, event filtering, MemoryEntry mapping, ISO timestamps). +- Behavioral: Flair-down ⇒ fast empty + one warning (test asserts elapsed + time < timeout budget — the guard must demonstrably fire); 0-hit search ⇒ + empty response, no exception. +- Scenario 4 as an integration test against a real local Flair. +- Quickstart-parity doc tested by actually running the swapped quickstart. + +## Explicitly out of scope (phase 1) + +`retrieve_profiles` parity, TTL/revision semantics, community-repo PR, +Long Horizon-specific wiring (repo not yet located — the seam covers it +regardless), per-user Flair principals, any Flair server changes. + +## Formerly-open questions — ALL RESOLVED by K&S review (2026-08-05) + +1. Scope mapping: compound-tag mechanism under one principal — approved by + both reviewers; README security language mandated above. +2. Timeout: 2s total, split connect 0.5 / read 1.5, full lifecycle incl. DNS. +3. Re-ingestion: stateless idempotency via deterministic record ids + (`${app}:${user}:${session}:${event.id}`); REM consolidates content, never + sees duplicates. + +## Implementation notes from final review (Sherlock) + +- **Sanitize `user_id` (and `app_name`) before compounding**: a value + containing `:` breaks the tag delimiter (`user_id="org:admin"` → + four-segment tag). Encode or replace colons; document the rule in README. +- The free-construct localhost set includes bracketed IPv6: `[::1]`. +- Hardening follow-up (phase 2, not a gate): `FLAIR_ALLOW_REMOTE_URL=` compared against the resolved URL, instead of `=1` — kills the + "set the flag, then typo'd the URL" mode. diff --git a/specs/ROSTER-PRESENCE-ADAPTER.md b/specs/ROSTER-PRESENCE-ADAPTER.md new file mode 100644 index 0000000..2bce60c --- /dev/null +++ b/specs/ROSTER-PRESENCE-ADAPTER.md @@ -0,0 +1,233 @@ +# Roster Presence Adapter — real data for the Observatory (ops-i7u6) + +**Status:** APPROVED with changes (Kern APPROVE×2 16:10/16:12Z, Sherlock CHANGES 16:14Z, all folded below 2026-08-05) — cleared for implementation +**Bead:** ops-i7u6 · **Supersedes:** aggregate-rockit.mjs (retired 2026-08-05), PR tpsdev-ai/observatory#3 (to be closed) +**Owner:** Flint (spec) · implementer TBD after design review + +## Problem + +The public Observatory (`tps.dtrt.harperfabric.com/Observatory`) advertises "LIVE" while +every roster row is frozen June fiction: rockit's office last updated 2026-06-23T01:58Z +(the final successful run of the old aggregator), the other three offices at the +2026-06-11 one-shot seed. Member rows are hardcoded (wrong models, invented tasks, +a fiction "nathan" member). Meanwhile the real data already exists: **Flair Presence +on rockit is live right now** (signed beats, activity enum, heartbeat freshness) — +we built the product feature and then didn't dogfood it here. + +## Root causes being fixed (not papered over) + +1. **No live writer.** The only writer ever (aggregate-rockit.mjs, 60s launchd job) + ran out of a *branch-hopping dev checkout* (`~/ops/tps-observatory`). When the + checkout left `cp-aggregate`, the script vanished from the working tree and the + job failed every 60s for six weeks. Nothing alerted. (Class: control aimed at a + mutable path — see memory `feedback_a_control_can_point_at_the_wrong_path`.) +2. **Seeded fiction never expired.** `Office.status` is a *stored string* set to + "online" at write time and never derived from `lastSeen`, so stale offices claim + liveness forever. (Class: free-form string vs derivable truth. Full fix is + Phase 2 — derive in RosterView; Phase 1 makes stored state honest.) +3. **Silent failure was invisible.** Six weeks of exit-1 every minute, zero signal. + Phase 1 ships with an explicit failure alert. Silence must not look like success. + +## Design (Phase 1 — smallest honest slice) + +A single adapter script on rockit, run by launchd, that reads the **local public +Presence endpoint** and pushes it through the **existing signed IngestEvents path**. +No prod deploy. No new tables. No UI change. + +``` +GET http://localhost:9926/Presence (public read, Flair product surface) + │ map fields (below) + ▼ +POST https://tps.dtrt.harperfabric.com/IngestEvents + X-TPS-Ed25519: TPS-Ed25519 rockit::: + { officeId: "rockit", agents: [...], events: [], syncedAt: } + (syncedAt is required by the IngestPayload interface — the server doesn't + validate it at runtime, but conform anyway; Kern) +``` + +### Scenarios (design targets) + +1. **Nathan opens /Observatory** → rockit shows the real agents (flint/kern/sherlock/…) + with true activity + fresh heartbeats; no fiction members, no fake models; + other offices show `offline` (post-cleanup) instead of June "online". +2. **An agent's daemon dies** → its beat stops → Flair derives `idle` (90s) then + `offline` (600s) → the adapter passes that status through → Observatory shows + the agent **offline (calm greyed-out state)** within ~1–11 min. Two honesty + notes (Kern): the renderer's fourth visual state, `stale` (crash glitch), is + **unreachable in Phase 1** — presence never emits it; it returns with Phase 2 + RosterView derivation. And offline agents stay INCLUDED in the push (status = + `offline`, real beat time as `lastSeen`) rather than being skipped: a skipped + row would freeze at its last label ("idle") forever, which is exactly the + frozen-fiction failure this spec exists to kill. Cost, accepted and named: + an offline member's `lastHeartbeat` column still refreshes with push time — + that column means "last ingestion" in Phase 1 (see mapping table). +3. **The adapter itself breaks** (local Flair down, prod unreachable, key missing) → + after **5 consecutive failed pushes (~5 min)** it sends one TPS mail to flint + (actor: roster-push; state: N consecutive failures + last HTTP status; remedy: + check local :9926 then tps.dtrt reachability; rearms on success). **Kern: the + alert must arrive BEFORE the 600s staleness flip, not with it** — 5×60s does, + 10×60s doesn't. Failure log lines carry ONLY timestamp, officeId, HTTP status, + and a short error class (`ECONNREFUSED`, `HTTP 429`, `timeout`) — never + response bodies, headers (they hold a live signature), or stack traces + (Sherlock's constraint; the error class covers Kern's diagnosability ask). + Successful pushes log NOTHING — silent success, matching presence-beat + (Kern). The script loops internally with a 60s sleep and + exits nonzero ONLY on unreadable/missing key (the loud-failure case); launchd + KeepAlive is the crashed-process backstop, not the retry mechanism — otherwise + ThrottleInterval-bounded restarts hammer a dead endpoint at 6× the design rate + (Kern). +4. **tps.dtrt unreachable** → same as 3; one log line per failed cycle, no tight + retry loop (retry cadence = the normal interval). +5. **rockit reboots** (this morning's case) → job loads at boot; if local Flair isn't + up yet, cycles fail → scenario 3 path; first successful push ≤ ~2 min after + Flair recovers. No manual step. + +### Field mapping (only fields presence actually knows — no invention) + +| Member (roster) | Presence source | Note | +|-------------------|----------------------------------------|------| +| id | `rockit:${p.id}` | existing convention | +| officeId/agentId | `"rockit"` / `p.id` | | +| name | `p.displayName` | | +| role | `p.role` | real roles ("strategy-lead") | +| type | `"agent"` | humans appear only if they ever beat | +| model | *omitted* | presence doesn't know it; don't invent | +| status | `p.presenceStatus` | pass through product's derivation (`active\|idle\|offline`, thresholds 90s/600s). **Set explicitly for EVERY agent, offline included — do not filter offline agents in the mapping function** (Kern will verify exactly this in the diff; the server stores whatever `status` string arrives, so an omitted one is silently lost). | +| activity | `p.activity` | flair enum {coding,reviewing,planning,debugging,idle} | +| currentTask | **pushed as `""` — deliberate** | see "Deliberate exclusion" below | +| lastActivity | send as `agents[].lastSeen` = ISO-minute(`p.lastHeartbeatAt`) | **server-side reality (Kern):** IngestEvents maps payload `lastSeen` → `Member.lastActivity` and OVERWRITES `lastHeartbeat` with push time, discarding anything sent. We send the agent's **beat** time (liveness), not `activityUpdatedAt` — `lastSeen`-derived staleness (Phase 2) must key off liveness, and the beat is the liveness signal. | +| lastHeartbeat | *not sent* | Observatory `Member.lastHeartbeat` therefore means **"last successful ingestion"**, not the agent's beat — named here so the field can't lie; the server fix rides with Phase 2's RosterView work (same file, one deploy). | + +**Naming trap (Kern):** presence `lastActivity` is an *enum string* ("coding"), +roster `Member.lastActivity` is a *timestamp*. The adapter uses +`activityUpdatedAt` and must carry a code comment saying so. +**Timestamp precision (Sherlock):** all pushed timestamps truncate to the +minute (`toISOString().slice(0,16)+'Z'`) — a public page needs no +sub-minute precision, and exact millisecond beats are a surveillance surface +(work hours, sleep patterns). + +`events: []` in Phase 1 (OrgEvent feed is Phase 2). Office row heals server-side: +IngestEvents already sets `status:"online"`, `lastSeen`, `agentCount` on each accepted push. + +### Deliberate exclusion: `currentTask` (and why the adapter reads anonymously) + +Flair itself **content-gates** `currentTask` / `flairVersion` / `harperVersion` on +`GET /Presence`: anonymous callers get `null`; only callers presenting a valid +TPS-Ed25519 signature see them (`resources/Presence.ts` ROSTER_ALLOWLIST + +`includeVerifiedFields`). RosterView on tps.dtrt is **fully public**. An adapter +that signed its local read and republished task text to a public page would use +our own product's escape hatch to bypass our own product's boundary — real task +strings ("Reviewing flair#NNNN auth fix") can leak repo names and security work. + +So Phase 1: the adapter reads `GET localhost:9926/Presence` **anonymously** — +the gated fields arrive as `null` by construction, nothing sensitive can be +republished even by bug, and no extra key or principal exists. (Shape over +policy: prefer removing the capability to validating its use.) Showing tasks on +the Observatory is a Phase 2 product decision with its own review — options there: +an authenticated Observatory view, or an explicit public-safe task field agents +opt into. + +**Boundary honesty (Sherlock finding, tracked as ops-nv9d):** rockit's Flair +currently binds `*:9926` with the macOS firewall off, so "anonymous local read" +is really "anonymous LAN read" today. The adapter neither widens nor depends on +that boundary (it reads only ungated fields), but the bind-all-interfaces P0 is +the real fix and stays with its own bead — not silently absorbed here. + +**Staleness pass-through debt (Kern, written down so Phase 2 remembers why):** +`presenceStatus` is a pass-through of Flair's product derivation (90s/600s). +The Observatory's own `staleThresholdSeconds` is independently 600s. Two owners +of one truth agree *today*; if either threshold moves they diverge, which is the +same class of bug this spec exists to kill. Accepted for Phase 1 because both +are 600s and Phase 2's RosterView-side derivation closes it. + +### Live-push gate (added after implementation incident 2026-08-05) + +**Dry-run is the DEFAULT; live pushing requires `ROSTER_PUSH_LIVE=1`, set only +in the launchd plist.** A bare `node push-rockit-roster.mjs` prints the exact +outbound payload and exits 0 without network contact with tps.dtrt. Why the +inversion: the first implementation pass ran the real loop "to test it" and +pushed unreviewed code's output to production through the real signature — +a written NEVER-POST instruction did not stop it, because written constraints +are not controls. With this shape, the casual/dev invocation is structurally +incapable of touching prod, and only the deploy artifact (the plist) carries +the live flag. (This replaces the earlier `--dry-run` flag design — the safe +mode must be the default, not an opt-in.) + +### Placement & cadence (the stable-path lesson) + +- Script: `~/ops/scripts/roster/push-rockit-roster.mjs` — ops repo main, same home + and precedent as the presence scripts (ops-i3vw). **Never** inside + `~/ops/tps-observatory` (branch-hopping checkout — that's root cause #1). +- Plist: `~/ops/launchd/ai.tpsdev.roster-push.plist`, installed to + `~/Library/LaunchAgents`, KeepAlive, no inline env secrets (key is read from + file path at runtime). +- Interval **60s** (server rate limit 10s → 6× headroom; office stale threshold + 600s → 10× headroom; ~5 agents ≪ batch limit 100). + +### Signing & secrets (Sherlock) + +- Reuses the exact proven signer from push-roster.mjs: payload + `rockit:::POST:/IngestEvents`, header `X-TPS-Ed25519` (gateway strips + `Authorization`), nonce = 8 random bytes hex per push, ts freshness window ±(5m/30s) + enforced server-side, nonce replay 401 server-side. +- Key: `~/.tps/secrets/rockit-office.key` (already registered as rockit's + Office.publicKey since June). Never printed, never in argv, never in the plist. + Missing/unreadable key ⇒ log + exit nonzero (loud), not skip (silent). +- **The daemon holds no admin credential.** Admin Basic + (`~/.tps/secrets/flair.dtrt.fabric`) is used once, by Flint's hand, for the + one-time cleanup below — it never enters the adapter or the plist. +- Reads only a public local endpoint; pushes only to the office's own row-space + (server enforces office scoping via the signature). + +### One-time cleanup (scripted, dry-run first — Sherlock override of "by hand") + +Not freehand curl: a script `~/ops/scripts/roster/cleanup-fiction.mjs` with the +explicit ID list below, `--dry-run` (default: prints what it WOULD delete) and +`--execute`, using the REST resource paths (`DELETE /Member/{id}`, `/Event/{id}`, +admin `PUT /Office/{id}`) — never raw SQL. Flint runs it; the dry-run output and +the execute output both go in the bead. Reviewable, repeatable, auditable. +1. `newton`, `pulse`, `tps-anvil` offices: `status` → `"offline"` (they have no + live writer yet; honest state until their hosts get adapters). +2. Delete their June-fiction Member rows (`newton:quill`, `newton:reed`, + `pulse:pulse`, `tps-anvil:anvil`) and the rockit fiction rows the adapter + won't re-create (`rockit:nathan`; stale seeded models get overwritten by + the first real push). +3. Delete the two June seed Event rows (`rockit:e1`, `rockit:e2`, `tps-anvil:e1`). + +### Verification (before calling it done) + +- Positive: `curl RosterView` shows rockit members matching live `GET /Presence` + within one interval; kill test — stop an agent's beat, watch staleness propagate. +- **Fiction-model check (Kern):** after the first real push, confirm the old + seeded `model` strings are gone from rockit Member rows, not retained by a + partial put — if Harper keeps unspecified fields, the cleanup script must + delete the rockit fiction rows too, and this test is what decides it. +- Key-unreadable path: chmod the key away in a scratch copy → script exits + nonzero loudly (Sherlock: never skip-and-continue). +- Negative (the check must be able to fire): block prod URL (or run with bad key) + → observe failure log lines, and the 10-failure TPS mail actually arrives. + A guard test needs its positive control. +- Reboot lane: `launchctl kickstart` after unload/load; confirm recovery per scenario 5. + +## Phase 2 (separate beads, not this slice) + +- **Derive office/member display status from `lastSeen` vs `staleThresholdSeconds` + in RosterView** — kills the stored-"online" lie class permanently. +- OrgEvent → roster Event feed (real events instead of `[]`). +- Adapters for tps-anvil / pulse / newton hosts (needs per-office keys registered). +- Repo hygiene: merge PR #2 (roster-cp2 — prod currently runs it via tarball, + unmerged), close PR #3 (cp-aggregate — superseded by this), commit the + tarball-deployed `office-space.html` into the repo. +- End-state: when federation carries Presence hub-ward, RosterView reads presence + directly and the adapter is deleted. The adapter is a bridge, not the destination. + Ground truth today: prod `/Presence` returns 0 agents; federation syncs exactly + four tables (Memory, Soul, Agent, Relationship — `resources/Federation.ts`), + OrgEvent is registry-marked `federation: "excluded"`, and Presence isn't in the + registry at all. Flair's own `doctor` docs state spoke heartbeats are invisible + to the hub unless agents beat straight to it. Extending federation to presence + is a flair product decision (and interacts with P0s ops-xllz/zu5x), not + something this slice reaches around. + +## Explicitly out of scope + +Prod code deploys, UI changes, TPS CLI subcommands, other hosts, federation work.