VIRGO

Hub API

원본
docs/design/HUB-API.md
고정된 커밋
d62e85485903a5537ee2b73c14201597e29bdaa5
sha256
f1eefdd139b861e526e000f9b15fb8518ee49134b99374b9d7d3c2fdf2b3a4bd

이 페이지는 요약이 아니라 명세 원문입니다. 위에 고정된 원본에서 그대로 렌더링되며, 본문은 한 글자도 바뀌지 않고 표현만 더해집니다.

명세 본문은 영어가 정본입니다. 번역본을 두면 진실의 출처가 둘이 되므로 의도적으로 번역하지 않습니다. 제목·목차·안내는 한국어로 제공합니다.

Virgo Hub API (v1 draft — greenfield build)

Status: new surface definition for the v2 greenfield installation. The hub is the single authority; CLI, web dashboard, adapters, and personas are all clients of this one API. No client re-implements hub rules.

Design principles

Every rule in this document is an application of one of these. A change that cannot be derived from them is a new principle and gets decided as one — not added as a case.

  1. One authority. The hub decides; clients and satellites consume decisions. No rule is enforced in two places.
  2. Identity is explicit. Every actor is named, validated against grants, and carried through; nothing is inferred, defaulted, or aliased.
  3. State commits with its evidence. A mutation and its receipt are one commit; external effects are driven from committed intent and evidenced by their own observation.
  4. Local things are judged locally. Liveness, process identity, and delivery are the duty of the hub on the same machine; no component reasons about another machine's processes or files.
  5. Push, never poll. Delivery is event-driven end to end: a message moves because it arrived, a connection resumed, or an effect completed — never because a timer fired. Durable queues absorb absence; reconnection drains them. Periodic waking, stall-polling, and any scheduled substitute for a failed push are design defects, not recovery mechanisms. Observability (status, duties) may WATCH progress and raise faults; it never performs delivery.
  6. Repetition terminates. Any automatic retry is finite and event- justified, and ends in success or a surfaced fault. Nothing repeats silently forever.
  7. Honest absence. Missing data, failed reads, and unserved routes are reported as exactly that — never rendered as empty success.
  8. Renderings regenerate; records migrate. Config and artifacts are rendered from canon and may be regenerated freely; stores of record are never regenerated, only explicitly migrated with evidence.

Shape

  • Topology: a hub runs on EVERY machine. One machine is the root hub and is the only place the store (FalkorDB + streams) lives; all other machines run satellite hubs. Every seat — CLI, persona, adapter, web — talks ONLY to its local hub on loopback; machines connect to each other through exactly one authenticated hub-link (satellite ↔ root) that relays pub/sub both ways. No per-seat tunnels, no seat ever dials a remote machine.
  • Authority stays at the root. Satellites relay and cache; they never decide grants and never mint authoritative receipts. Reach is masked by the workspace/project/role hierarchy (the derived-reach rule) evaluated at the root; satellites enforce the projection they are handed, fail closed when it is stale or absent.
  • Liveness is checked where it is local. A seat's liveness is its local hub's local judgment (process/session on the same machine); a machine's liveness is its hub-link heartbeat at the root. A hub never judges a remote PID (principle 4).
  • Delivery is a local-hub duty. The local hub performs final delivery to its seats (channel push, pull queues, wake). Store-and-forward: when the hub-link is down, outbound queues locally and replays under the envelope idempotency digests; duplicates are impossible by construction.
  • Engine daemons are local-hub responsibilities. Engine-native session daemons (e.g. the codex app-server) run on the machine that hosts their sessions; the local hub supervises their lifecycle (via the virgo agent family) and performs engine-native turn-starting delivery through them — codex inbound is injected via the LOCAL app-server thread, claude inbound via local channel push. A hub never reaches into another machine's engine daemon or rollout storage (principle 4).
  • Delivery is push-only (principles 5 and 6). A seat's local hub delivers on arrival and on reconnection; retries are finite and end in success or a surfaced fault. No component anywhere in v2 wakes seats on a schedule or polls for work on their behalf.
  • Per-engine channel driver contract. Each engine integrates through a driver shipped in the release that owns four things: (1) the session LAUNCH recipe — the exact argv/env a persona session needs for its channel to work (claude requires the broker channel flag; codex requires app-server residency and a thread mapping) — so the hub launches sessions correctly and a human never has to remember flags; (2) the inbound delivery/wake method; (3) the liveness probe; (4) the login/auth verification. virgo persona up/launch and virgo agent add consume the driver; engine differences never leak above this interface.
  • Control plane: HTTP/JSON on the hub machine, loopback-first (127.0.0.1:PORT); remote machines reach it through their local hub — never exposed publicly. Web dashboard is served from the same process family and consumes the same API.
  • Data/transport plane: Redis Streams on the hub store (message delivery, receipts, presence). MCP tool surface for agents stays stable; the MCP server is a thin client of this API.
  • Auth: per-machine device token issued at onboard (join), stored target-only (0600, non-symlink), registry keeps SHA-256 digests only. Owner/root actions additionally require the owner gate (CLEAR tokens for gated verbs). Every mutating call carries the acting identity; every response that changes state returns a receipt (hash-addressed, durable).

Resources and verbs

ResourceVerbsNotes
/machinesregister, list, profile get/set, heartbeatregistration at onboard-join; profile is the canonical path/identity record
/agentsadd, list, remove, repair-statusengine installs per machine; model catalog per engine
/adaptersadd, list, remove, bind, doctorbinding = adapter x persona-address x direction; live only after delivery receipt. Adapters TERMINATE at the hub: inbound human-channel traffic enters the hub as envelopes and is routed by address to the target seat's local hub for final delivery; outbound goes seat -> hub -> adapter. An adapter never connects to a persona session directly, and a session never speaks a channel protocol
/workspacesadd, listhome implicit; no remove in v1
/projectsadd, list, onboardonboard instantiates the coding-work template
/personasadd, list, status, up, down, checkpoint, launch, retirelifecycle actions are audit-gated; single-active-copy enforced here, not in clients
/recordssync, status, source add, read (address/manifest/record)content-addressed; manifest-first read discipline
/skillslist, add, edit, propose, approveshared-skill activation requires signed approval record
/dutieslist, add, run-logscheduled obligations; hub scheduler executes, emits receipts
/statussystem viewmachines, personas, fences, queues, channel health, last-consumed ages
/attestverify-context, promote-completionreceipt-bearing claims only
/renderrender, drift-scancanon -> artifacts (CLAUDE.md, settings, hooks, AGENTS.md, skills, MCP config)

Dashboard read surface

Read-only endpoints the dashboard consumes; all device-auth (browser read-session), all receipt-bearing. Canonical contracts live in virgo-web/dashboard/dev/API-NEEDS.md until implemented; this section fixes the surface list so the client and hub cannot drift:

  • /graph/nodes (filterable: kind, workspace, persona, label, hash prefix; enumerable facets or facet counts) and /graph/search (with per-result match attribution — the hub explains WHY a row matched; clients never re-derive matching).
  • /personas/{address} read (character overlay, bindings, registration — the per-persona read) and /personas/{address}/snapshots + /snapshots/{manifest_hash}: manifest-addressed, verified answered by the hub or null (never re-derived client-side), payload REDACTED AT SOURCE, supersedes_manifest_hash for walkable history.
  • /setup-checklist read (deferred-from-onboarding items with state).
  • /receipts/{hash} read — closes the record-viewer contract's third call site (receipts opened from Status).
  • Paging is forward-only (next_cursor opaque, no prev) — decided; clients must not fabricate backward paging.

Invariants enforced at the API (not in clients, not in docs)

  1. Single active copy per persona logical identity (fence tokens; up refuses while a live fence exists elsewhere).
  2. Gated verbs refuse loudly without evidence (CLEAR token, context receipt); refusals are machine-readable and logged.
  3. Receipts everywhere: every state change returns a durable, hash-addressed receipt; silent success does not exist. See the receipt contract below for what "everywhere" can and cannot mean.
  4. Canon is the source; files are renderings. Config artifacts are only written through /render; drift is detected, never adopted.
  5. No secret leaves its machine: tokens and store passwords live only where they are used; the registry stores digests.
  6. Send is not delivery: adapter and transport operations distinguish enqueue from consume; delivery receipts close the loop (channel liveness is measured by last-consumed age, not process health).
  7. One canonical identity (principle 2): a seat carries exactly one authenticated project/agent identity, presented identically across app-server threads, MCP tools, edge messaging, run/task ownership, receipts, and deputy reporting. Missing or conflicting identity fails closed — the operation refuses loudly; nothing defaults, aliases, or downgrades an identity silently (principle 2).

Receipt contract (invariant 3)

The root hub is the sole authoritative minter, and that is enforced where a minter is constructed rather than stated as a convention: the boundary requires an explicit root authority and refuses a satellite — and refuses an omitted authority differently, so a later slice cannot acquire authoritative minting by forgetting to consider the question. Receipts are content-addressed at virgo:receipt:<receipt_sha> and retrievable by that hash; there is no secondary index, so the receipt's own address is the only way to it.

State and its receipt commit in one script. A caller does not mutate and then persist a receipt — it hands the boundary a plan (the writes and their preconditions) and the boundary applies the writes AND the receipt together, or neither. That is what makes both halves of the invariant structural rather than a matter of discipline: no state change without a receipt, and no receipt for a change that did not happen. Route modules are given the boundary and never a store executor, so the wrong ordering is not expressible from a route; bootstrap and the persona adapters remain legitimate writers of their own reviewed namespaces.

External effects are not in that transaction, and never claim to be. Graph module commands, files, processes and adapters cannot join a store transaction. They are driven idempotently FROM state that has already been receipted, so the receipt records the intent that is now authoritative and the effect converges toward it. When an effect is observed to have landed it emits its own, distinct observed-effect receipt. "We decided this" and "this is now true of the world" are two facts, hashed separately, and a single receipt never asserts a guarantee the mechanism behind it cannot keep.

A receipt's identity is fixed before the commit. Its hash covers the operation id, a digest of the exact state plan, the resource and action, the validated actor {project, agent, device_id}, the result, and the commit instant in canonical UTC. The instant belongs to the COMMIT, not to serving a response, and the caller holds it stable across retries — so an exact retry addresses the exact receipt. A retry whose response was lost therefore finds its own receipt: the boundary verifies the stored bytes are identical and that the state still matches the plan, then returns the stored receipt terminally without re-applying anything. If the state has drifted it refuses loudly rather than vouching for something no longer true.

Read attestations are not receipts. A read is answered with a receipt-shaped attestation that is computed and never persisted, may carry a null actor (device auth alone grants no seat), and is about the moment a caller was answered. A durable receipt always names a seat and is about the moment state changed. Nothing on the read path can mint or store one.

Identity wire contract (invariant 7)

  • Transport auth (device token) and acting identity are separate layers. Every request authenticates the DEVICE; requests that mutate state or create receipts additionally assert the ACTING identity in the header x-virgo-identity: <project>/<agent>.
  • The hub validates the asserted identity against the device's granted identity set. Absent or conflicting identity on a mutating call fails closed with a structured refusal; it is never defaulted, aliased, or inferred from the token alone.
  • Receipts embed actor: {project, agent, device_id} populated only from validated values — never echoed from unvalidated input.
  • Read-only GETs (e.g. /status) require device auth only; if the identity header IS present it must validate (conflict fails closed — never ignored).
  • Scope for implementation: V2-103 ships the typed identity seam inside the receipt envelope; V2-106 implements header propagation and the per-surface fail-closed conformance tests.

Browser read-session (dashboard v1)

The dashboard page cannot read the device token (0600 file). For the read-first dashboard the loopback bind IS the trust boundary: the hub serves the dashboard only on loopback and mints a short-lived read-only session (HttpOnly, SameSite=Strict cookie, TTL minutes, renewable) for loopback page loads, with the session's actor fixed to the hub device's own identity. The session grants read endpoints only; it can never call a mutating endpoint, regardless of headers. Remote (non-loopback) dashboard access and any mutating UI are OUT of v1 and require a real owner-auth design in a later item — this section must not be stretched to cover them.

Persona lifecycle mutation surface (V2-305 — implemented)

POST /lifecycle/personas/{persona_name}/{up|down|checkpoint}

Off the read prefix, deliberately. /api/v1 honours the browser read-session cookie above. Keeping every lifecycle mutation on a separate prefix that takes a device bearer token and nothing else makes "a page can never mutate" a fact about the route table rather than a check somebody has to remember. A cookie-only request to /lifecycle/... is 401, not 403.

The persona is named by its for-life name, never by a seat address: addresses are generational and reusable, so .../work/core/lead/down would name a chair rather than its occupant.

Request body — exact keys per verb, and no paths at all.

verbaccepted keys
up, downoperation_id, token, recovery_operation_id?
checkpointoperation_id

Any other key is refused BY NAME (persona_request_field_not_accepted:<key>), never ignored — a caller who sent a token on a checkpoint believed an audit gated it, and quietly dropping the field would let them keep believing that. There is no repository, subdirectory, or target_path on the wire: the hub resolves where a persona lives (see Placement below), because a CLEAR token binds a persona, a machine and a tree hash, and a caller-supplied path is a path the audit says nothing about.

operation_id is the caller's stable retry key. Reusing it replays the same durable operation and returns the receipt already stored (V2-105); a new id runs the operation again instead of resuming it.

Response. {version: 1, ok: true, receipt, result} where receipt is the full DURABLE receipt, re-read from the store by hash after the commit — a hash alone would ask the caller to trust that a receipt they cannot see exists. An unreadable receipt is hub_receipt_unreadable (500), never a bare success.

Status mapping.

conditionstatus
supervision missing (persona_supervision_not_implemented*, V2-508)503
lifecycle deps not composed (no store) — hub_lifecycle_unavailable503
seat/operation held elsewhere, persona unplaced or placed elsewhere, retired409
unknown persona name, unknown route shape404
non-POST on a lifecycle route405
malformed body, refused field, persona_not_active, token failures400
missing acting identity / ungranted seat400 / 409 (V2-106 codes)

Ordering is part of the contract: device auth and the acting identity are resolved BEFORE the route table, the body, or the lifecycle deps are touched, and the V2-508 refusal for up/down runs before the placement resolver. A call that cannot prove who is acting performs no resolver call, no store read, and no runtime contact; a call that arrives at a hub which cannot supervise spends no token and moves no state.

The hub's own seat: root/hub

root/hub is a structural system seat, not a persona. Canon refuses to register anybody at that address — including a retired row, so no tombstone can exist there either — and the address grammar answers it as {kind: "system"} rather than parsing it as a persona.

The hub API config carries it explicitly:

"system_seat": { "machine_id": "<this machine>", "identity": "root/hub" }

Validated at config load exactly like browser_session: the device must be known and must have been GRANTED that identity, or the hub fails to start. It is never inferred from the device token — a token says which machine is calling and nothing about who is acting. null/absent means no system seat is configured, and callers that need one refuse rather than choose.

Onboarding grants exactly this one seat on the first machine's device and writes the same {machine_id, identity: root/hub} binding to BOTH system_seat and browser_session — separately, and separately validated. They stay two keys because a later install could legitimately serve the lifecycle and no dashboard, and one key would make that unsayable; writing only one would install a hub that refuses its own dashboard with read_session_not_configured.

No persona seat is granted at install time: the first persona does not exist yet, and a grant issued before its occupant is standing authority nobody asked for.

Placement: where a persona's state lives

Placement is canon (registry v4, additive and optional): placement: {machine_id, source_anchor?}. Canon validates SHAPE only; whether the machine exists is a question about an installation, and is answered where the placement is WRITTEN (virgo persona add --machine, checked against the hub's validated device registry).

The hub resolves a placement to a live tree at call time:

  • unplaced → persona_not_placed:<name>:run_virgo_persona_add_--machine (409). Unplaced is a legal state, and the refusal names the decision to make.
  • placed on another machine → persona_placed_on_another_machine:<id>:remote_placement_is_v2-506 (409). Acting locally "on its behalf" is how a second live copy appears on the wrong host.
  • default live tree <state_root>/personas/<name>, from the CONFIGURED state root — not from wherever the canon registry happens to sit, and never an absolute path written in code. source_anchor overrides it for one persona.
  • repository is the anchor's enclosing git toplevel and subdirectory is the anchor relative to it. An anchor that IS the toplevel is refused with the path still visible (persona_live_tree_is_repository_root:...), because V2-304's snapshot contract rejects "" and "." — a persona's live tree cannot be a repository root.

The CLEAR signing key

Read on demand, and only by up/down — both of which refuse on V2-508 first. A hub that has never issued a CLEAR token therefore composes and serves checkpoints with no key on disk at all. The key is provisioned by the first CLEAR issuance flow, owner-only (0600) under the configured state root, owned by the root/hub seat; it is NOT generated at onboarding, and there is no default or compiled-in key. An unused signing secret on every machine is a secret nobody is watching, and a defaulted one would verify tokens anybody could mint.

Reincarnation (V2-307)

POST /lifecycle/personas/{name}/reincarnate renews a persona's CONTEXT without ending the persona. It accepts operation_id and an optional recovery_operation_id, and takes no CLEAR token: it is scheduled hygiene, and its gate is EVIDENCE rather than permission.

The sequence, and why it is in this order:

  1. Checkpoint, driven by the verb itself. Requiring a caller to have checkpointed first would make the central safety property somebody else's responsibility, and a scheduled step has nobody to ask.
  2. Verify it landed — bound to this generation, produced by this operation's own checkpoint, and still what the current pointer names.
  3. Pin the sources and project the briefing, from the persona's canon-bound record sources in precedence order. Projected BEFORE anything is cleared, so unreadable records cost a persona nothing.
  4. Declare, which advances the generation and stores the briefing. The generation moves HERE and not at settle: admission resolves a persona's current generation from this record, so fencing the cleared session has to be true before the clear rather than eventually after it.
  5. Clear, then resume — the only irreversible effect in the lifecycle, under effect ids derived from the ORIGINAL operation so a takeover drives the ids the first operator already used and cannot clear twice.
  6. Verify the registration, then settle. resumeWithBriefing returns the seat's holder as the new life; it is pinned to this persona, generation and machine, reconciled against the authoritative holder registry, and its fence must have ADVANCED past the one that observed the checkpoint. Without that, a driver returning without registering would publish a seat that reads healthy and can accept nothing. Only then does the record say active, one generation on, owing its own checkpoint — and the terminal journal carries both fences, because "the registration advanced" is a claim one number cannot make.

The persona's for-life name and seat address are unchanged; only which life is current changes. A cleared-but-unresumed session is a stuck durable operation in state reincarnating, taken over only by naming recovery_operation_id — never by timeout, and never by an automatic down.

The briefing is a PURE PROJECTION with no generated prose: pinned sources in precedence order, records ordered by (kind, recorded_at, content_hash) within each, deduplicated across the whole document, every line carrying the exact content hash it came from, and its own limitations stated in it. It is stored at virgo:lifecycle:briefing:<hash>, and the bytes handed to a resuming session are exactly the bytes that hash addresses.

Supervision seam (V2-508)

runtime (start/stop), the live-holder lookup, and session context control (clearContext + resumeWithBriefing, both idempotent under a stable effect id, the latter returning the seat's registration as the new generation) arrive together or not at all: a driver that can start and stop an engine is the thing that can also clear and re-brief its session, and a runtime is exactly what makes live holders exist. Until then up/down and reincarnate answer 503 by name, and the holder lookup REFUSES rather than returning "nobody" — null is a real answer meaning the seat is free, and a seam that cannot look must not produce it.

Delivery/transport contract (Redis Streams)

  • Per-address inbox streams with consumer groups; lease + heartbeat; redelivery on lease expiry; ack closes the receipt.
  • Envelopes carry a mandatory canonical seat actor {project, agent}; actor is required on publish and covered by the idempotency/content digests, so an actor swap is a different message. Envelope versions predating the actor field are refused outright — the greenfield store has no legitimate legacy producer, and no silent normalization/compatibility path may exist. Any future v1-data migration is an explicit, separately-authenticated ingest tool, never a transport read path.
  • Authority split: the hub is the sole grant authority for actor validation; transport enforces presence, grammar, and hash binding only and never re-authorizes against a grant set.
  • The message contract is JSON-native: one envelope object with versioned schema, mandatory actor, and separate zones — original{text, source, source_hash} and relay_note{from, text} — so relayed human words and AI-added interpretation are distinct fields, never concatenated prose. The hub verifies the original hash and renders plain-language labels ("AI opinion" marking is rendering, not convention).
  • Presence/registry (peer list, activity, summaries) is served from the hub registry, not inferred from socket liveness.
  • Receipt cardinality: THREE per message. A publish commits ONE state receipt together with the stream append, in one eval. An ack commits TWO in one eval — a state receipt for the completed ledger and the XACK, and a separate observed-effect receipt for the delivery, whose content comes from the acking consumer. One receipt cannot honestly be both a state mutation and an external observation.
  • The transport mints nothing. It describes each delivery mutation as a typed, closed FRAGMENT and hands it to an injected committer; the root hub's committer composes fragment and receipt in a single script. Fragments are data, never Lua: a boundary that executed a script it was handed would be vouching for code it never read. Satellites relay over the hub-link and do not write the transport store at all.
  • Publish admission is typed and discriminated. A seat publishes under its own registration and is held to the SAME lease-plus-generation rule consume applies, so a fenced generation loses its outbound voice as well as its inbox. The hub publishing as itself is a named case whose actor must equal the admitted identity, and the production surface never accepts an admission from a caller — publishFromHub derives it from configuration.
  • Relay zones live in the payload as original{text, source, source_hash} and relay_note{from, text}, optional and structurally validated whenever present. source_hash is verified as sha256 of original.text on publish AND on consume. That is an INTEGRITY check, not provenance: it proves the zones were not swapped or edited in transit and nothing about where the words came from. Provenance belongs to the adapter that captured them (V2-401).
  • A consumed entry is verified against the publish ledger — entry id and content digest — before any handler sees it, because the idempotency key binds routing and actor but not payload.

What stays out of v1

  • Public exposure of any endpoint (owner-mesh only).
  • Cross-installation federation.
  • Workspace deletion semantics.
  • Engine-native plugin marketplaces; adapters/engines are release-shipped.