PROTEUS docs
v0.1.0 Jeremy Hall

The world model

proteus/model/schema.py — the five dataclasses and four enums that describe a synthetic estate, and how they are stored.

The world model is the single source of truth for a synthetic estate. Nothing else holds state: the renderer is pure, the emitters are stateless, and the AI layer's only job is to edit this document.

It lives in proteus/model/schema.py, which is frozen. A model is fully described by its JSON form, so two runs with the same model and the same time range produce identical telemetry.

WorldModel#

@dataclass(slots=True)
class WorldModel:
    name: str
    region: str
    schema_version: int = SCHEMA_VERSION   # 1
    revision: int = 1
    environment: str = "production"
    seed: int = 1337
    tenants: list[Tenant] = field(default_factory=list)
    services: list[Service] = field(default_factory=list)
    infra: list[InfraNode] = field(default_factory=list)
    faults: list[Fault] = field(default_factory=list)

seed is what makes a render reproducible; see the renderer. environment becomes the deployment.environment resource attribute. revision is bumped by every successful write through the web API, which is how the GUI detects a stale client.

Four lookups are provided, and they are the ones the rest of the code uses:

Method Returns
service(name) the Service called name, or KeyError
infra_node(node_id) the InfraNode with that id, or KeyError
faults_for(target, ts) faults active against target at ts
tenants_on(node_id) tenant ids permitted on a node; all tenants when shared_by is empty

That last one has a trap attached, covered in Faults: "permitted to share this node" is not the same set as "actually running something on this node".

Service#

@dataclass(slots=True)
class Service:
    name: str
    archetype: Archetype
    runtime: Runtime
    tenant: str | None = None        # None = shared platform service
    instances: int = 3
    base_rps: float = 20.0
    base_latency_p50_ms: float = 45.0
    base_latency_p99_ms: float = 220.0
    base_error_rate: float = 0.002   # fraction, 0..1
    depends_on: list[str] = field(default_factory=list)   # service names
    uses_infra: list[str] = field(default_factory=list)   # InfraNode ids
    attrs: dict[str, str] = field(default_factory=dict)
    kind: str | None = None

One node in the topology CloudWatch Omni will auto-discover. The four base_* fields are the healthy baseline every generator starts from; a FaultEffect deviates from them rather than replacing them.

depends_on names other services and draws the dependency edges. The renderer emits a CLIENT span per outbound call alongside the SERVER span, sharing a trace_id, and that CLIENT/SERVER pair is what Omni turns into an edge.

uses_infra names InfraNode ids and is what makes shared-infra faults and noisy-neighbor structural rather than narrated.

attrs layers extra resource attributes on. It is applied last in catalog/_shared.py::base_attributes, so it wins over anything the renderer derived. proteus/web/validate.py therefore refuses service.name in attrs: it is the key every depends_on edge is drawn from, so overriding it emits telemetry under a different name than the model uses and the dependency never appears in Omni. Rename the service instead.

A name is a foreign key, which makes a rename a graph edit. Five fields reference a key by value, and one of them draws from two namespaces at once:

flowchart LR
    SVC["Service.name"]
    NODE["InfraNode.id"]
    TEN["Tenant.id"]
    DEP["Service.depends_on"]
    USES["Service.uses_infra"]
    OWNER["Service.tenant"]
    SHARED["InfraNode.shared_by"]
    TGT["Fault.target"]

    DEP -- "service names" --> SVC
    USES -- "node ids" --> NODE
    OWNER -- "tenant id" --> TEN
    SHARED -- "tenant ids" --> TEN
    TGT -- "a service name" --> SVC
    TGT -- "or a node id" --> NODE

So changing a name means rewriting every reference to it, and missing one loses an edge without failing validation — the service renders with no callers and faults stop propagating through it.

The two arrows leaving Fault.target are the reason the ambiguity matters: a single string field is read against both key spaces, so a name existing in both cannot be resolved to one of them. Use proteus rename rather than editing the JSON, and note the consequence of Fault.target carrying two namespaces: a name that exists as both a service and an infra node cannot have its fault targets rewritten unambiguously, so the command refuses instead of guessing.

archetype and kind#

archetype says how to render. kind says what it is, and only when archetype is CUSTOM.

Service(name="orders-queue", archetype=Archetype.CUSTOM,
        runtime=Runtime.MANAGED, kind="sqs-queue")

This pairing is what lets an estate hold an SQS queue, a DynamoDB table or a wholly user-defined resource without the Archetype enum growing once per AWS service. It is described in full in the catalog.

Because a mismatched pairing renders nothing at all — a service that saves cleanly, injects successfully and is silently empty in the console hours later — proteus/web/validate.py refuses both halves of the mistake: archetype: "custom" with a missing or unregistered kind, and a kind set on any other archetype.

InfraNode#

@dataclass(slots=True)
class InfraNode:
    id: str
    kind: InfraKind
    shared_by: list[str] = field(default_factory=list)  # tenant ids; [] = all
    capacity: dict[str, float] = field(default_factory=dict)
    attrs: dict[str, str] = field(default_factory=dict)

A contended resource. shared_by is what makes noisy-neighbor structural: an empty list means every tenant in the estate.

capacity is free-form and read by name. The keys the code actually looks for are max_connections (by faults/library.py::_pool_ceiling, so exhausting a pool lands on the real ceiling) and whatever key a MetricSpec names in capacity_key for a Shape.CAPACITY metric. The schema's own comment gives the conventional set: vcpu, mem_gib, storage_gib, partitions, max_connections, iops, nodes.

There is no AWS::RDS::DBInstance behind an rds-instance node, and there is not meant to be. See provisioning.

Tenant#

@dataclass(slots=True)
class Tenant:
    id: str
    name: str
    traffic_weight: float = 1.0   # relative share of total request volume

A fictional customer sharing the estate. Tenants exist so that noisy-neighbor is a real relationship between real nodes rather than a label. A Service with tenant=None is a shared platform service: it can be starved, but it is nobody's noisy neighbor.

Fault#

@dataclass(slots=True)
class Fault:
    id: str
    kind: FaultKind
    target: str              # a Service.name or an InfraNode.id
    start: datetime
    end: datetime
    severity: float = 0.6
    params: dict[str, Any] = field(default_factory=dict)
    hidden: bool = False
    note: str = ""

target is polymorphic on purpose: most kinds aim at a service, noisy-neighbor must aim at an InfraNode, and several work either way. The catalog reports which per kind as targets: "service" | "infra" | "both".

params is the per-kind tuning dict. Every key is registered with its units and default alongside its builder, so faults.library.params_for(kind) cannot drift from the code — see Faults.

hidden is True in hunt mode. The renderer needs hidden faults, so GET /api/model returns them, and the client is required to filter them out and show only a count. The first live hunt leaked its own answer through proteus show; do not reintroduce that anywhere.

Two methods live on Fault:

def active_at(self, ts: datetime) -> bool:
    return self.start <= ts < self.end

def ramp_at(self, ts: datetime) -> float:
    """Severity at `ts`, ramped in over the first 10% of the window."""

The window is half-open, so end is never active. ramp_at returns 0.0 outside the window and otherwise scales severity linearly over the first 10% of the span, floored at one second, because real incidents do not start square. faults/resolve.py::intensity_at adds one exception on top of this, for OOM_KILL; see Faults.

The four enums#

All four are StrEnum, so a member serialises as its own string and round-trips through JSON without a codec.

Archetype — 13 members. Twelve are hand-written generators: ALB, NGINX_WEB, APP_SERVICE, LAMBDA, RDS_POSTGRES, ELASTICACHE, KAFKA, OPENSEARCH, EKS_WORKLOAD, ECS_WORKLOAD, BEDROCK_AGENT, BROWSER_RUM. The thirteenth, CUSTOM, is the delegation mechanism rather than a workload.

Runtime — 6 members: EKS, ECS, EC2, LAMBDA, MANAGED, BROWSER. Drives which resource attributes are emitted. MANAGED means AWS-managed with no pods or hosts.

InfraKind — 8 members: RDS_INSTANCE, KAFKA_CLUSTER, OPENSEARCH_DOMAIN, EKS_NODEGROUP, ECS_CLUSTER, EBS_VOLUME, CACHE_CLUSTER, LOAD_BALANCER.

FaultKind — 14 members, each with a coordinated signature across signals. The full table is in the Appendix; the mechanism is in Faults.

Serialisation#

model.to_dict()      # datetimes as ISO-8601 UTC strings
model.to_json()      # canonical: indent=2, sort_keys=True
WorldModel.from_dict(raw)
WorldModel.from_json(text)

from_dict raises ValueError on a schema_version mismatch rather than attempting a migration. SCHEMA_VERSION is currently 1.

Timestamps are normalised on the way in by _parse_ts, which accepts an ISO-8601 string or a datetime and always returns tz-aware UTC. A naive datetime is treated as UTC rather than rejected. On the way out, _isoify walks the dict recursively and converts every datetime.

to_json sorts keys so that the JSON — and therefore the fingerprint — is stable.

fingerprint()#

def fingerprint(self) -> str:
    """SHA-256 prefix of the canonical JSON, excluding faults."""

A 16-hex-character identity for the estate, not for the document. Faults are excluded deliberately: they churn constantly, and the thing a CloudFormation stack provisions scaffolding for is the estate shape. That exclusion is what lets proteus doctor distinguish "your stack is stale" from "you injected another fault":

model vs stack   WARN   world model fingerprint 4cfb59fa74ddf795 does not match
                        the deployed 9a2b... - the estate has changed since
                        bootstrap, so some services may have no log group or alarm

The same value is written into the template's Metadata and exposed as the WorldModelFingerprint stack output. See provisioning.

Persistence#

proteus/model/store.py is the whole storage layer — 86 lines, five functions.

default_path() -> Path      # $PROTEUS_HOME/world.json, else ~/.proteus/world.json
load(path=None) -> WorldModel
exists(path=None) -> bool
save(model, path=None) -> Path
backup(path=None) -> Path | None
list_backups(path=None) -> list[Path]

The model is plain canonical JSON, so you can read it, diff it, and check it into git.

Saves are atomic. save writes to a temp file in the same directory via tempfile.mkstemp, then Path.replaces it into place, and unlinks the temp file on any exception. An interrupted run cannot leave a half-written model behind.

backup runs before a destructive edit. It copies the current model to world.<UTC timestamp>.bak.json and returns the path, or None when there was nothing to back up. proteus chat calls it before applying an AI edit, so a bad edit is always recoverable; list_backups returns them newest first.

load raises FileNotFoundError with a usable hint rather than a bare traceback:

no world model at /Users/<you>/.proteus/world.json. Run `proteus plan` to create one.

PROTEUS_HOME#

Every path to user state must honour PROTEUS_HOME, resolved at call time.

def default_path() -> Path:
    home = os.environ.get("PROTEUS_HOME")
    return (Path(home) / "world.json") if home else DEFAULT_PATH

The os.environ read is inside the function, not at import. This is not style. A module-level Path.home() / ".proteus" / ... constant cannot be isolated by a test or by a side-by-side estate, and that is exactly how tests came to write into a real hunt ledger and a real spend ledger. A synthetic entry in the spend ledger silently refuses the user's next genuine run with a correct-looking "monthly cap reached" message.

Three modules follow this pattern and each says so in its docstring:

State Path Resolver
world model $PROTEUS_HOME/world.json model/store.py::default_path
spend ledger $PROTEUS_HOME/spend.json guard/policy.py::ledger_path
pricing cache $PROTEUS_HOME/pricing-{region}.json pricing_cache.py::cache_path
canvas layout $PROTEUS_HOME/layout.json the web layout sidecar
hunt ledger under $PROTEUS_HOME ai/_hunt_ledger.py

DEFAULT_LEDGER_PATH and DEFAULT_PATH exist as module constants for readability, but they cannot honour the environment; call the resolver. If you add a new piece of durable state, follow default_path() — and put the os.environ lookup inside the function.

Presets#

proteus/model/presets.py builds a model from a curated starting point. preset_names() returns the available ones and build(name, region) returns a WorldModel. Verified on this build:

['minimal', 'northwind-rx']      default: northwind-rx
northwind-rx: 18 services, 8 infra nodes, 3 tenants, 0 faults

The presets have a second job. The world model deliberately has no archetype-to-runtime table — Service carries both independently — so the only non-invented source of "which runtimes make sense for this archetype" is the curated presets. web/catalog_view.py::_probe_runtimes reads them for exactly that, and falls back to every runtime for an archetype that appears in no preset, so a newly added archetype stays placeable.

Proteus is MIT-licensed. Built by Jeremy Hall.

Synthetic telemetry for CloudWatch Omni. Everything it emits is fabricated; nothing in it describes a real system.