VIRGO

Store schema

원본
docs/design/STORE-SCHEMA.md
고정된 커밋
d62e85485903a5537ee2b73c14201597e29bdaa5
sha256
521a417fa9e6623d2190bc9243b1fabb2829dbd12a04a13d855ef3e26dbffd21

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

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

Store schema v1

Status: implemented contract (V2-104). This document enumerates every key namespace and every graph label and edge type that Virgo v2 provisions and uses, including namespaces reserved here for a named future writer. It describes what the code actually does — src/hub/store.ts derives the same list from the same constants, and a test asserts the two agree, so this document cannot quietly drift from the store.

Topology: the root hub owns the store

There is ONE store in an installation, and the root hub owns it: a single FalkorDB container bound to loopback, holding both the Redis keyspace and the record graph. Satellite hubs do not get a store of their own — they relay to the root hub (HUB-API.md, multi-machine topology). Everything below describes the root hub's store, and "the store" always means that one.

What bootstrap does, and what it deliberately does not do

virgo hub start provisions the store after — and only after — the container is verified running, healthy, and exactly the spec this release pins. That ordering is a safety property rather than a convenience: provisioning into a container that merely exists, or that is not the pinned spec, would write schema state into the wrong store.

Provisioning is deliberately minimal, because the libraries that own these namespaces already create their own keys on first use:

  • it touches the record graph so the graph exists (this writes no nodes, so an empty store stays empty);
  • it writes a single schema marker recording the version, graph, node label, node kinds, relationship types, namespace list, and the marker's own schema_sha256 digest over all of that.

The digest is real, not decorative: it is recomputed on every read and a marker whose digest does not address its own contents is refused (hub_store_schema_digest_mismatch) rather than trusted to decide whether the store matches this release. The SET NX that writes it must return exactly OK; any other reply leaves the outcome unknown, and an unknown outcome is reported as hub_store_schema_write_unconfirmed rather than as provisioned.

It does not pre-create streams, consumer groups, leases, or record keys. Those are created by their owners, under the invariants below.

Re-running is idempotent. An identical marker is left exactly as it is. A marker describing a *different* schema is refused (hub_store_schema_conflict) rather than overwritten: a store provisioned under another schema is not this release's store to repair. Two hubs racing the first bootstrap resolve through SET NX, so only one can claim to have provisioned it.

That has a consequence worth stating plainly rather than discovering: the namespace list is inside the digest, so adding a namespace changes the schema. A store provisioned before virgo:receipt:<receipt_sha> was enumerated carries a marker describing a different schema and will be refused, not upgraded. No migration path is offered here and none should be inferred.

Credentials never reach this path. The Redis executor is constructed at the CLI edge and supplies the password during connection; the bootstrap module receives an already-authenticated executor and never sees, logs, or passes a credential on a command line.

The edge does not simply read that file. Before the secret is read it is proven to be a regular, single-link, non-symlink file owned by the current user with no group or other access, and the descriptor is re-checked after opening so the file that was validated is the file that is read. A secret that fails any of those checks is refused; the file is never rewritten or re-permissioned to make a read succeed, because the running store already trusts its contents.

Key namespaces

<address> is the transport address key (scope[:project]:role), wrapped in {} so every key for one address lands in one Redis hash slot. <purpose> is one of coordination, human-surface, lifecycle, knowledge.

namespaceownerpurpose
virgo:store-schemahub store bootstrap (src/hub/store.ts)Single marker recording the provisioned schema version and its digest.
virgo:{<address>}:stream:<purpose>transport (packages/transport)Per-address inbox stream, one per purpose; consumer groups read from it.
virgo:{<address>}:published:<purpose>transport (packages/transport)Publish ledger binding an idempotency key to its entry id and content digest.
virgo:{<address>}:completed:<purpose>transport (packages/transport)Completion ledger; a replayed delivery is acked instead of re-run.
virgo:{persona:<persona_name>}:leasetransport (packages/transport)Single-active-copy lease token for a persona's current life. Keyed by the FOR-LIFE persona name, not by a seat address (V2-305): an address changes hands between generations, so an address-keyed lease lets a retired occupant keep consuming its successor's work and blocks that successor from registering until the lease happens to expire. Expiry is not an answer to an identity question.
virgo:{persona:<persona_name>}:fencetransport (packages/transport)Monotonic fence counter; a stale holder is refused after it advances. Shares a hash tag with the lease and registration because one script touches all three — and never with a stream.
virgo:{persona:<persona_name>}:registrationtransport (packages/transport)Process identity proof recorded for the current registration.
virgo:record-blob:<sha256>record ingestion (src/records)Content-addressed source bytes; the address IS the digest.
virgo:record-node:<content_hash>record ingestion (src/records)Content-addressed record node payload mirroring its graph node.
virgo:record-edge:<edge_hash>record ingestion (src/records)Content-addressed record edge payload mirroring its graph edge.
virgo:record-manifest:<manifest_hash>record ingestion (src/records)Content-addressed manifest listing a sync's files, nodes and edges.
virgo:record-ledger:<sha256>record ingestion (src/records)Content-addressed reviewed ledger a sync was applied from.
virgo:record-current:<address>record ingestion (src/records)Current manifest pointer per source address; the only mutable record key.
virgo:record-pending-review:<address>record ingestion (src/records)Interpretation awaiting human review; the graph never advances from it.
virgo:record-source-init-receipt:<address>:<manifest_hash>record ingestion (src/records)Receipt proving a source was initialized at a given manifest.
virgo:record-sync-receipt:<address>:<manifest_hash>record ingestion (src/records)Receipt proving a sync was applied and verified at a given manifest.
virgo:record-sync-last:<address>record ingestion (src/records)Last sync attempt summary per source address.
virgo:receipt:<receipt_sha>durable receipt primitive (src/hub/receipt.ts)Content-addressed durable receipt; written only by the receipt boundary. A state receipt is applied atomically with the plan it records; an observed-effect receipt records a separately observed external fact and writes no state. No secondary index, by decision.
virgo:persona-blob:<sha256>persona snapshot store (src/hub/persona-store.ts)Content-addressed persona snapshot blob; the address IS the digest.
virgo:persona-manifest:<snapshot_hash>persona snapshot store (src/hub/persona-store.ts)Content-addressed persona snapshot manifest bytes.
virgo:persona-current:<persona_id>persona lifecycle (src/hub/persona-lifecycle.ts)Current snapshot pointer per persona. The ONE mutable persona key; moved only under compare-and-set, and only after a complete read-back-verified ingest.
virgo:lifecycle:activation:<persona_name>persona lifecycle verbs (src/hub/persona-activation.ts)The authoritative activation record for a persona: state (preparing/active/reincarnating/stopping/inactive), generation, target machine, and the in-flight operation and checkpoint journals. Moved only by the receipt boundary, and only under compare-and-set on its exact predecessor bytes. reincarnating means the seat is UP and holding its registration while carrying no usable context (V2-307); the generation advances entering that state, before the context is cleared, because admission resolves a persona's current generation from this record — fencing the session being replaced has to be true before the clear, not eventually true after it.
virgo:lifecycle:operation:<persona_name>:<operation_id>persona lifecycle verbs (src/hub/persona-activation.ts)The predecessor bytes an operation's terminal transition expected, written with that transition. A record cannot contain its own predecessor, so a retry rebuilds the identical plan from this and V2-105 replay returns the receipt already stored instead of minting a second one.
virgo:lifecycle:briefing:<briefing_sha256>persona reincarnation (src/hub/persona-briefing.ts)The briefing a reincarnated session was resumed on, content-addressed and written in the same transaction as the declare that binds it. A retry re-reads this exact document rather than re-projecting one from record pointers that may have moved since — and a briefing a persona was actually resumed on is retained evidence rather than a claim. Never written unconditionally: the address is read first, so a differing value is a refused collision and not an overwrite.
virgo:lifecycle:clear-consumed:<token_sha256>CLEAR tokens (src/hub/clear-token.ts)Single-use marker for an audit token, keyed by the token's own digest. Written in the SAME script as the transition it gates, so a token is spent exactly when the transition it authorised happened — never before, and never without it.

Persona snapshots behind the hub boundary

RedisPersonaSnapshotStore implements the existing PersonaSnapshotStore seam against the two content-addressed namespaces above, so persona bytes genuinely live in the store rather than the schema naming a namespace nobody writes. It carries the same guarantees as the filesystem store: a value must hash to its own address to be written, is re-verified on read, and rewriting an address with different bytes is a refused collision rather than an overwrite.

The snapshot store itself still has no lifecycle behaviour by design. Ingest, restore, and pointer movement live in src/hub/persona-lifecycle.ts (V2-304), which takes the content-addressed store and the pointer store as two separate seams. They are separate on purpose: immutable bytes addressed by their own digest and a single mutable pointer that must move under compare-and-set are different invariants, and one interface carrying both would give every content-addressed store a concern it does not have.

The current pointer

virgo:persona-current:<persona_id> is written by RedisPersonaCurrentPointerStore and by nothing else. Three rules govern it.

  • It moves only after a complete, read-back-verified ingest. Writing blobs and a manifest and returning is write-then-trust: a store that accepted a write but cannot return it would report success, and the pointer would then advertise a snapshot no restore can complete. Ingest therefore reads the whole snapshot back and verifies every blob's size and digest before the pointer is touched.
  • It moves under compare-and-set. Every write states the value it expects to replace — SET NX for first publication, a server-side compare-and-swap otherwise — so a slow ingest cannot silently undo a newer one. An unexpected reply is an unconfirmed write and is never reported as success.
  • A failure never moves it. An ingest or restore that did not finish has not changed which snapshot a restore would use.

<persona_id> is the globally unique, for-life canon persona name — example-persona, example-seat — written [a-z0-9][a-z0-9._-]{0,127}. It is the same identity the hashed virgo.persona-snapshot/v1 manifest binds a snapshot to in its persona_id field, so the key and the artifact agree by construction rather than by convention.

Retirement tombstones the name permanently. virgo persona retire stamps retired_at and removes nothing: the row stays in the canon registry forever, and addPersona checks the name against every row rather than only the live ones, so a retired name is refused (persona_already_exists) whatever address the caller asks for. Name suggestion reads the same full set, so a retired name is never offered back either.

That permanence is not tidiness — it is the precondition for keying persona state by name at all. If a retired name could be handed to a new persona, virgo:persona-current:example-persona would come to address a different seat's snapshot without the pointer ever moving: everything holding that key would keep reading it and silently get someone else's state.

Addresses are generational, and deliberately not tombstoned (identity ruling 2026-08-09). A seat may be held by one persona after another: retiring the persona at root/secretary frees that address for a new persona, under its own for-life name, as a new generation. The registry keeps every generation and enforces at most one ACTIVE row per address, so "who holds this seat now" has exactly one answer while the history of who held it before is never discarded.

The two rules differ because the things they identify differ. An address answers *who holds this seat now* and must be transferable, or a seat could never outlive its first occupant. A name answers *who is this* and must be permanent, because it is what persona state is keyed by. Keying the store by the address instead would mean a successor inherited its predecessor's snapshot simply by taking the seat.

<persona_id> is not the canonical seat address (root/secretary, home/core/lead). RESIDENT-COLLABORATION separates the two deliberately: the address path is the transport address, the name is the owner-facing identity, and the registry enforces uniqueness on both. The grammars are disjoint — this alphabet contains no / at all — and no derivation between them exists anywhere in the code. None should be introduced: a mapping is precisely where two distinct identities would collapse onto one key.

Record graph

One graph: virgo_knowledge.

There is exactly one node label, VirgoRecord. A record's kind is a PROPERTY on that node (kind), not a label of its own — the store writes MERGE (n:VirgoRecord {content_hash:$hash}) ON CREATE SET n.kind=$kind, ..., so a graph modelled with per-kind labels would be one Virgo never creates. Nodes are keyed by content_hash; kind, workspace, persona, source, recorded_at, and payload are properties.

Values of the kind property (RECORD_KINDS_V2):

kind valuemeaning
personA person; virgo-canon/base/PEOPLE.md is the semantic authority.
decisionA reviewed decision record.
memoryA durable remembered fact.
findingAn observation or result.
work-recordA unit of performed work.
sessionA persistent logical-session record.
policyA reviewed canon invariant or authority rule.
procedureA reviewed operational trap and its confirmed recovery steps.

Relationship types are the exact uppercase Cypher types the store emits, derived from the edge kind (kind.toUpperCase() with - mapped to _). Edges are keyed by edge_hash and carry a payload property.

edge kindCypher relationship typemeaning
supersedesSUPERSEDESThis node replaces a prior node; history is kept, not deleted.
relatesRELATESUntyped association between two records.
dependsDEPENDSThis record depends on another.
reports_toREPORTS_TOReporting relationship, primarily between person records.

Invariants

  1. Content addresses are addresses. For every *-blob, *-node, *-edge, *-manifest, and *-ledger key the digest in the key names the bytes stored under it. A value that does not hash to its own key is corruption and is refused, never repaired.
  2. One mutable pointer per source. virgo:record-current:<address> is the only record key that moves. Everything else is append-only by construction, so history is never rewritten in place.
  3. The graph never advances from an unreviewed interpretation. Pending interpretations live in virgo:record-pending-review:<address> until a human reviews them.
  4. A materialized file is never a source of truth. Graph nodes are written first; rendered files and indexes are views of them.
  5. One active copy. lease plus a monotonic fence mean a logical identity runs in exactly one process; a fenced holder is refused rather than allowed to race.
  6. Delivery is exactly-once at the boundary. The publish ledger binds an idempotency key to a content digest, and the completed ledger closes it, so a replay is acknowledged instead of re-executed.
  7. Every key belongs to exactly one owner. The owner column above is the whole list, and a component does not write another component's namespace. Schema v1 covers the implemented namespaces plus namespaces explicitly RESERVED for a named future writer. virgo:persona-current:<persona_id> was such a reservation and is now implemented by V2-304's persona lifecycle. A reservation is always attributed; a namespace with no declared owner is never listed.
  8. The store is loopback-only. It is bound to 127.0.0.1 and reached through the hub; nothing outside the machine addresses it directly.