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" --> NODESo 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.