Architecture
The world model, the pure renderer and the emitters — three layers, and the reasons the seams sit where they do.
Proteus is three layers with two hard seams between them.
flowchart TD
AI["chat · hunt · GUI"]
WM["WorldModel"]
subgraph pure["render() — pure"]
FX["effect_at()"]
GEN["generator"]
end
REC["records"]
EM["OTLP · RUM emitters"]
RE["RecordingEmitter"]
AWS[("CloudWatch")]
AI -- "edit only this" --> WM
WM --> pure
FX -- "fault seam" --> GEN
GEN -- "record seam" --> REC
REC --> EM
REC -- "--dry-run" --> RE
EM --> AWSA world model (proteus/model/schema.py) is a document describing a
synthetic estate: services, the infrastructure they share, the tenants who own
them, and the faults currently in effect. It is plain JSON and it is the only
durable state the tool keeps, besides the CloudFormation stack and the hunt
ledger.
The renderer (proteus/render/engine.py) is a pure function from
(WorldModel, Window) to a stream of signal-neutral records
(proteus/render/records.py). No network, no file reads, no print, no
wall-clock reads, and no use of the random module.
Emitters (proteus/emit/) translate records into wire calls. RecordingEmitter
keeps them in memory instead, which is what --dry-run, the cost estimator and
every test use. Nothing above the emitter layer knows which one it has.
Why the record seam exists#
The renderer could have built OTLP protobuf directly. It does not, and the reason is testability of a specific kind.
A fault in Proteus is a coordinated signature across signals: CPU climbs, p99
stretches, a FATAL line appears in the logs, a span gains an container.oom_kill
event. Verifying that by hand means opening four CloudWatch consoles and forming
an opinion. Verifying it against records means asserting on a dataclass.
So SpanRecord, MetricPoint, LogRecord and RumEvent are deliberately
neutral: they carry no protobuf types, no SDK objects, and no endpoint knowledge.
proteus/render/ imports no AWS SDK and needs no credentials. That is the
property being bought, and it is why records.py is frozen.
Why the fault seam exists#
A fault never writes telemetry. proteus/faults/library.py returns a
FaultEffect — a bundle of 14 numeric multipliers and addends plus a narrative
payload — and the archetype generator applies it to its own baseline.
The alternative is every generator branching on FaultKind. That works for two
faults and twelve archetypes. At 14 kinds and 68 placeable workload types it is
952 combinations that each have to be written and none of which can be reasoned
about. With the effect seam, adding a fault touches one file under
proteus/faults/ and adding a workload touches one file under
proteus/catalog/, and neither touches the other.
The seam also buys something that would be awkward otherwise. noisy-neighbor
has two signatures: the victim slows down with no local cause, and the
aggressor looks merely busy. Because both are FaultEffect values,
proteus/faults/resolve.py can hand the right one to each service on a shared
InfraNode without any generator knowing that contention is a concept. See
Faults.
The frozen files#
Six files are frozen. They are the contracts every other module is written against, so changing one silently changes the meaning of code you are not looking at.
| File | What it fixes |
|---|---|
proteus/model/schema.py |
The world model: the estate's data shape and its JSON form |
proteus/render/records.py |
The four record types and the Signal enum |
proteus/emit/base.py |
The Emitter protocol, EmitResult, RecordingEmitter, EmitterSet |
proteus/faults/effects.py |
FaultEffect, its units, combine, scaled |
proteus/catalog/base.py |
ArchetypeGenerator, Tick, tick_rng, the registry |
pyproject.toml |
Dependencies and the package surface |
One further file is not frozen but is a cross-module contract:
proteus/catalog/_shared.py::LATENCY_BOUNDS_MS. Every latency histogram in the
project buckets over it, and _compute_shared.py::SLOW_BOUNDS_MS extends it.
It must stay strictly ascending and must stay a strict prefix of any extension.
Changing it changes the wire format of every latency metric, so it changes in one
pass with its extension or not at all.
CONTRACTS.md records that the frozen list once named only three of those six
files while the per-agent briefs named all six. An agent reading only
CONTRACTS.md could reasonably have concluded catalog/base.py was editable —
the one file most likely to tempt someone, since it holds the protocol everyone
implements. The list is now complete in both places.
If a contract genuinely blocks you: stop and say what you need. Do not work around it by editing a frozen file, and do not define a parallel type.
Module map#
proteus/
model/ schema.py (frozen), store.py, presets.py
catalog/ base.py (frozen), profile.py, generic.py,
12 archetype modules, profiles/ (56 declarative profiles)
faults/ effects.py (frozen), library.py, resolve.py
render/ records.py (frozen), engine.py, timeline.py
emit/ base.py (frozen), otlp.py, rum.py, _proto.py, _transport.py,
_logstreams.py, _diagnose.py
guard/ estimate.py, policy.py, _volume.py
cfn/ synth.py, deploy.py, _resources.py, _serialise.py,
_names.py, _state.py, _templates3.py
ai/ chat.py, hunt.py, bedrock.py
web/ server.py, api.py, api_ops.py, jobs.py, catalog_view.py,
validate.py, pricing_view.py, static/
cli/ main.py, inject.py, doctor.py, inspect.py, interact.py, banner.py
pricing.py, pricing_cache.py
Every file is under 500 lines, by rule. That is why several subsystems are one
public module plus private helpers: synth.py re-exports from _names.py,
_resources.py and _serialise.py; estimate.py re-exports from _volume.py;
otlp.py re-exports diagnose from _diagnose.py. Import the public module,
not the helper.
Determinism is a load-bearing property#
Every generator takes an explicit rng: random.Random. The random module is
never called at module level, and the renderer never reads a clock — time is
always passed in. The same WorldModel.seed over the same time range produces
identical records on every run, in any process, in any evaluation order.
proteus/catalog/base.py::tick_rng gets this by hashing rather than by advancing
a shared stream:
def tick_rng(seed: int, service_name: str, ts: datetime) -> random.Random:
material = f"{seed}:{service_name}:{ts.isoformat()}".encode()
digest = hashlib.blake2b(material, digest_size=8).digest()
return random.Random(int.from_bytes(digest, "big"))
Because each (service, timestamp) pair derives its own stream, ticks are
independent and order does not matter — which is what will let the renderer
parallelise later without breaking a golden file.
The blind spot this design has#
Determinism makes wrong answers stable, and a stable wrong answer passes every check that compares output to previous output.
Three real defects in this project were all of that shape:
proteus.agent.invocationsreported a flat 6 per minute — its sampling cap — against a modelled 1,001 to 3,079, and token counters under-reported by roughly 500x. Deterministic, order-free, idempotent, reproducible across processes.- A projected cost came out about 600x high, because
guard/_volume.pyguessed at three possible signatures forestimate_volume, matched none, and silently fell back to a heuristic table. - Three
MetricSpecentries declaredLATENCYin seconds because AWS genuinely publishes those counterparts in seconds. The renderer builds every latency histogram in milliseconds and passesunitstraight through, so they read 1000x fast.
None of those was caught by reading. A golden file would have frozen each one as correct.
So the project's verification discipline is: render and diff, never read.
Assert rendered values against the world model and the unit the contract states —
never against recorded output, and never against the spec that produced the
output. proteus/catalog/base.py::ArchetypeGenerator.metrics states the rule for
counters directly: a capped archetype must scale its SUM counters from the sample
back up to the modelled rate, because capping bounds payload size and is never
the reported rate.
Where to go next#
The pipeline in order: the world model → the catalog → faults → the renderer → emitters.
The two things that wrap it: the cost guard, which decides whether a run is allowed to send at all, and provisioning, which creates the AWS-side scaffolding the telemetry lands in.
The web GUI is a view over all of it.