RFC-0015: Central State Plane — Design#
Status: Implemented
Date: 2026-07-31
Series: Central State Plane series index
Supersedes / extends: Sharing state remote/local coordination backends (keeps local + HTTP; generalizes storage)
Depends on: Shipped metagit.core.state (LocalFileBackend, RemoteHttpBackend, BackendBundle)
Enables: RFC-0016 (catalog), RFC-0017 (harness), RFC-0018 (ontology)
Plan: 2026-07-31-rfc-0015-central-state-plane.md
Operator docs: central-state-plane.md
ACL migration (Phase 4, design-only): 2026-07-31-rfc-0015-acl-namespace-migration.md
Summary#
Introduce a generic document state plane underneath Metagit’s coordination stores so the same concurrency-safe protocol can back local JSON, today’s HTTP ops API, and optional cloud document stores (DynamoDB, MongoDB). Domain services keep calling familiar backends; new work targets a single DocumentStore protocol keyed by (org_id, workspace_id, namespace, key).
RFC-0015 does not move the org catalog out of .metagit.yml (that is 0016), does not invent a new agent runtime (0017), and does not define an ontology engine (0018). It leaves reserved namespaces and a stable CAS model so those RFCs plug in without another storage rewrite.
Goals#
- Define a single
DocumentStoreprotocol:get,put(CAS),append,list_prefix,delete(CAS),describe. - Preserve byte-compatible local default for objectives / handoffs / approvals / events.
- Keep HTTP (
RemoteHttpBackend) as a first-class client transport against/v3/ops/*. - Add at least one cloud DocumentStore implementation behind an optional extra (DynamoDB recommended as the reference; MongoDB as second).
- Adapt existing
BackendBundledomain protocols onto the plane viacoord.*namespaces (no CLI verb churn). - Expose plane diagnostics on
metagit://gate/status→state_backend(backend kind, org/workspace ids, extras installed — never secrets). - Document deployment shapes: direct cloud from agents, or ops server hosting the cloud store while agents use HTTP.
- Thread-safe within a process; cross-host safety via store-level CAS.
Non-Goals#
- Org catalog /
.metagit.ymlreplacement (RFC-0016). - New task/scheduler/merge/ACL engines (reuse 0007–0013; migrate persistence in later phases).
- Ontology / Atlas / GitNexus query engines (RFC-0018).
- Multi-tenant SaaS product, billing, or hosted Metagit Cloud.
- Strong consistency across namespaces in one transaction (per-document CAS only in v1).
- Changing shapes of
Objective,HandoffItem,ApprovalRequest. - Requiring boto3/pymongo in the base package.
- Push/SSE for events (polling remains).
Decisions (locked)#
| # | Decision |
|---|---|
| D1 | Approach: generic DocumentStore + domain adapters (not per-domain Dynamo classes). |
| D2 | MVP domains: coord.objectives, coord.handoffs, coord.approvals, coord.events (and existing whole-doc envelopes). |
| D3 | Phase 2 namespaces (interfaces only in 0015): acl.* reserved; migration plan documented, not required to ship. |
| D4 | Phase 3 namespaces (interfaces only): task.*, schedule.*, merge.* reserved. |
| D5 | Identity: org_id + workspace_id on every key; local backend may ignore org_id and map workspace_id → filesystem root. |
| D6 | Reference cloud backend: DynamoDB first (conditional writes map cleanly to CAS). MongoDB second with the same protocol. |
| D7 | Deps: metagit-cli[state-dynamodb], metagit-cli[state-mongodb] optional extras. |
| D8 | Config: extend StateConfig.backend literal; env METAGIT_STATE_BACKEND, METAGIT_STATE_ORG_ID, METAGIT_STATE_WORKSPACE_ID, plus backend-specific vars. |
| D9 | Ops server may host cloud store: metagit web serve resolves DocumentStore the same way clients do; HTTP clients unchanged. |
| D10 | In-memory store for tests ships in-tree (no extra). |
Architecture#
┌──────────────────────────────────────────────────────────────┐
│ CLI / MCP / Web / Skills │
│ ObjectiveService, HandoffService, ApprovalService, … │
└────────────────────────────┬─────────────────────────────────┘
│ BackendBundle (unchanged call shape)
┌────────────────────────────▼─────────────────────────────────┐
│ Domain adapters (coord.* → DocumentStore) │
│ objectives.json envelope ↔ namespace coord.objectives │
└────────────────────────────┬─────────────────────────────────┘
│ DocumentStore Protocol
┌────────────────────────────▼─────────────────────────────────┐
│ StatePlane resolver │
│ local | http | dynamodb | mongodb | memory │
└───┬──────────┬──────────┬──────────┬──────────┬──────────────┘
│ │ │ │ │
LocalJSON HttpOps DynamoDB* MongoDB* Memory
(default) (today) (extra) (extra) (tests)
Package layout (proposed):
src/metagit/core/state/
base.py # existing BackendBundle protocols (kept)
document.py # NEW: DocumentStore Protocol, DocumentRef, StateRecord
plane.py # NEW: StatePlane facade, namespace constants
resolver.py # EXTEND: resolve DocumentStore + BackendBundle
local.py # EXTEND: implement DocumentStore on files
remote.py # EXTEND: HttpDocumentStore over /v3/ops or new /v3/state/*
dynamodb.py # NEW (optional import): DynamoDocumentStore
mongodb.py # NEW (optional import): MongoDocumentStore
memory.py # NEW: InMemoryDocumentStore
adapters/
coord.py # NEW: BackendBundle ← DocumentStore for coord.*
errors.py # existing
retry.py # existing
Key model#
DocumentRef:
org_id: str # e.g. "acme" or "_" for single-tenant local
workspace_id: str # e.g. hash/path id or configured name
namespace: str # e.g. "coord.objectives"
key: str # e.g. "document" for whole-doc, or entity id later
StateRecord:
ref: DocumentRef
body: dict[str, Any] # JSON-serializable envelope
token: StateToken # opaque; SHA-256 for local/http; Dynamo version attr, etc.
DocumentStore protocol (normative)#
class DocumentStore(Protocol):
def get(self, ref: DocumentRef) -> StateRecord | None: ...
def put(
self,
ref: DocumentRef,
body: dict[str, Any],
*,
expected: StateToken,
) -> StateToken: ...
def append(
self,
ref: DocumentRef,
item: dict[str, Any],
) -> dict[str, Any]: ...
def list_prefix(
self,
org_id: str,
workspace_id: str,
namespace: str,
*,
prefix: str = "",
limit: int = 100,
) -> list[DocumentRef]: ...
def delete(
self,
ref: DocumentRef,
*,
expected: StateToken,
) -> None: ...
def describe(self) -> dict[str, Any]: ...
CAS rules (all backends):
- Empty / missing document:
getreturnsNone; firstputusesexpected=""orexpected=Noneper existing remote contract (If-Match: ""). - Stale
expected→ raiseStateConflictError. appendis a backend-defined atomic append for log-like keys (handoffs create path, events); may use composite keys under the namespace.- Mutating services keep using
state.conflict_retries.
Reserved namespaces#
| Namespace | Owner RFC | Phase |
|---|---|---|
coord.objectives |
0015 | MVP |
coord.handoffs |
0015 | MVP |
coord.approvals |
0015 | MVP |
coord.events |
0015 | MVP |
acl.branches |
0015 phase 2 / 0007 migrate | later |
acl.leases |
0015 phase 2 | later |
acl.claims |
0015 phase 2 | later |
acl.worktrees |
0015 phase 2 | later |
acl.agents |
0015 phase 2 | later |
task.graphs |
0015 phase 3 / 0008 | later |
schedule.policy |
0015 phase 3 / 0012 | later |
merge.queue |
0015 phase 3 / 0011 | later |
catalog.workspace |
0016 | later |
ontology.* |
0018 | later |
Unknown namespaces are allowed for forward compatibility; core never writes them in 0015.
Interfaces#
App config#
config:
state:
backend: local # local | http | dynamodb | mongodb | memory
url: "" # http only
token: "" # http bearer
conflict_retries: 1
org_id: "" # default "_" when empty
workspace_id: "" # default: derived from session/manifest root
dynamodb:
table: ""
region: ""
endpoint_url: "" # localstack / dynalite
mongodb:
uri: ""
database: ""
collection: "metagit_state"
Environment overrides#
| Variable | Purpose |
|---|---|
METAGIT_STATE_BACKEND |
local | http | dynamodb | mongodb | memory |
METAGIT_STATE_URL |
HTTP ops base (forces http when set, same as today) |
METAGIT_STATE_TOKEN |
Bearer token |
METAGIT_STATE_ORG_ID |
Org partition |
METAGIT_STATE_WORKSPACE_ID |
Workspace partition |
METAGIT_STATE_DDB_TABLE |
DynamoDB table name |
METAGIT_STATE_DDB_REGION |
AWS region |
METAGIT_STATE_DDB_ENDPOINT |
Optional custom endpoint |
METAGIT_STATE_MONGO_URI |
Mongo connection URI |
METAGIT_STATE_MONGO_DB |
Database name |
MCP / diagnostics#
metagit://gate/status → state_backend gains:
{
"backend": "dynamodb",
"org_id": "acme",
"workspace_id": "platform-ws",
"url": "",
"token_configured": false,
"extras": {"dynamodb": true, "mongodb": false},
"conflict_retries": 1,
"env_overrides": { "...": true }
}
Skills#
Extend metagit-sharing-state: document plane backends, org/workspace ids, extras install, and “ops server hosts Dynamo” deployment. No new skill in 0015.
CLI#
No new top-level verbs required. Optional diagnostic:
metagit appconfig show --format json # includes state block
# optional thin helper (nice-to-have, not required for MVP):
metagit context state doctor --json
Persistence#
Local DocumentStore#
Map DocumentRef → path under session root:
.metagit/state/{namespace}/{key}.json
For MVP adapters, also keep reading/writing legacy paths (sessions/objectives.json, etc.) so existing installs do not migrate on upgrade. Dual-read: prefer plane path if present, else legacy. Dual-write: write both until a future cleanup RFC, or (preferred smaller blast radius) adapters keep legacy paths as the local encoding of coord.* whole documents (namespace is logical only). Locked preference: local encoding of coord.* remains today’s file paths; DocumentStore is a logical API over those files for local, and a real keyspace for cloud/http.
HTTP DocumentStore#
- MVP: map
coord.*whole documents to existing/v3/ops/objectives|handoffs|approvals|events(no breaking change). - Optional later: generic
GET/PUT /v3/state/{namespace}/{key}for ACL/catalog namespaces when those migrate.
DynamoDB DocumentStore (reference)#
Table schema (single-table):
| Attribute | Role |
|---|---|
pk |
ORG#{org_id}#WS#{workspace_id} |
sk |
NS#{namespace}#KEY#{key} |
body |
JSON map / string |
token |
string (content hash or version uuid) |
updated_at |
ISO-8601 |
Conditional put: attribute_not_exists(pk) OR token = :expected.
GSI optional for list_prefix if needed; MVP may Query on pk + sk begins_with NS#{namespace}#KEY#{prefix}.
MongoDB DocumentStore#
Collection document:
{
"_id": {"org_id": "…", "workspace_id": "…", "namespace": "…", "key": "…"},
"body": {},
"token": "…",
"updated_at": "…"
}
CAS via findOneAndUpdate filter on _id + token.
Domain adapters (MVP)#
adapters/coord.py implements existing ObjectiveBackend, HandoffBackend, ApprovalBackend, EventsBackend on top of DocumentStore:
- Whole-document keys:
key="document"for objectives/approvals; handoffs may useappendtokey="items"or keep whole-doc + append helper matching today’s semantics. - Envelope shapes unchanged:
{"objectives":[…]},{"requests":[…]},{"handoffs":[…]}.
resolve_backend(workspace_root) continues to return BackendBundle; internally it builds DocumentStore then wraps adapters (except pure RemoteHttpBackend path may stay as today’s implementation until HttpDocumentStore lands — either is fine if contract tests pass).
Concurrency & threading#
- DocumentStore implementations MUST be safe for concurrent use from multiple threads in one process (no shared mutable cursor without locks).
- Cross-process / cross-host: CAS only; services retry on
StateConflictError. - No distributed locks in 0015.
Security#
- Cloud credentials come from the environment / AWS default chain / Mongo URI — never from
.metagit.yml. state.token, Mongo URIs, and similar redacted inappconfig preview.describe()never returns secrets.- HTTP backend keeps scheme allow-list (
http/https).
Phased delivery#
Phase 0 — Protocol + memory + contract tests#
DocumentStore,DocumentRef,StateRecord, namespace constants.InMemoryDocumentStore.- Parametrized contract tests (get/put CAS/append/list_prefix/delete).
Phase 1 — Local + HTTP as DocumentStore + coord adapters#
- Local + HTTP implement
DocumentStore(HTTP may wrap existing routes). BackendBundlevia adapters; default path green on existing tests.- Diagnostics extended on
gate/status. - Docs + skill updates.
Phase 2 — DynamoDB extra#
DynamoDocumentStorebehind lazy import.- Packaging extra + table bootstrap notes in docs.
- Contract tests against Dynamo local / moto (prefer moto or endpoint_url).
Phase 3 — MongoDB extra#
MongoDocumentStore+ extra + contract tests (mongomock or testcontainer — prefer mongomock for unit speed).
Phase 4 — ACL namespace migration plan (design-complete; optional code)#
- Document mapping from
.metagit/{branches,leases,…}→acl.*keys. - No requirement to flip default persistence in 0015 ship; follow-up PR may implement.
Acceptance#
- Existing local coordination CLI/MCP tests pass with no config change.
- Contract suite passes for
memory,local, andhttp(http against test ops server or mocked urllib). - With extras installed and test endpoint, Dynamo (and later Mongo) pass the same contract suite.
- Stale CAS write raises
StateConflictError; retry path still works viaconflict_retries. gate/statusreports effective backend, org_id, workspace_id, extras presence.metagit-sharing-stateskill documents plane backends and deployment shapes.- Modality / changelog entry when implementation lands.
Dependencies#
| Depends on | Provides to |
|---|---|
Existing core/state, ops HTTP, appconfig StateConfig |
RFC-0016 catalog documents |
| RFC-0017 harness envelopes in plane namespaces | |
| RFC-0018 ontology document slices + list/query | |
| Future ACL/task persistence migration |
Open questions#
- Should HTTP grow generic
/v3/state/{namespace}/{key}in Phase 1, or only when ACL/catalog needs it?
Recommendation: defer generic routes until Phase 4 / 0016; mapcoord.*to existing ops routes. - Workspace id derivation algorithm when unset (path hash vs manifest
name)?
Recommendation: prefer configured id; else stable hash of resolved session root path; document clearly. - Dual-write local plane paths vs logical-only namespaces?
Recommendation (locked above): logical namespaces; local keeps legacy file paths forcoord.*.
Spec self-review notes#
- No TBD placeholders left that block an implementation plan.
- Scope is one foundation RFC; catalog/harness/ontology are separate designs in this series.
- Concurrency model matches shipped remote-state semantics to avoid two CAS dialects.