rate-limit resets on every restart, so it cannot express a spend budget #69
Labels
No labels
autonomy
async-consult
autonomy
epic
autonomy
headless
autonomy
live-collab
coherence-core
priority
P0
priority
P1
priority
P2
priority
P3
priority
P4
qa-fixture
role/ai
role/creator
role/design
role/director
role/engineer
role/exec
role/human
role/ops
role/qa
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
coilyco-flight-deck/mcp-beaver#69
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Requested by Kai.
rate-limitis currently a rate shape. Making it a budget needs the bucket to survive a process restart, and that needs a backing store. Filing with the measurements rather than the fix, since this is a runtime change and Engineering owns it.Driver:
coilyco-bridge/deploynow runs a keyed, metered upstream for the first time (Exa web search, deploy#448). Every prior consumer was a keyless public-good API where the bucket existed to be a polite neighbour, and losing it on restart cost nothing. With a metered upstream the same bucket is the only software bound on spend, and it is not one.What it does today
internal/mcpserver/ratelimit.go:Constructed in memory when the spec is parsed at process start. Nothing persists it.
Measured against a build of main on 2026-08-15 with
rate-limit "2/24h":ward-mcp: rate limit wait: rate: Wait(n=1) would exceed context deadline,isError: true.The 0.19s behaviour is good and should be preserved:
limiter.Wait(ctx)errors as soon as the projected wait exceeds the request deadline instead of blocking to it, so a spent budget is a fast reportable tool error rather than a hung turn.Why the restart matters more than it sounds
An MCP pod rolls whenever its guardfile or values change. The two Exa pods rolled twice on their first day. So a
72/24hbudget intended as $0.50/day returns to full on each roll, several times a day.And there are two buckets, not one. Echo and Deep run separate pods from the same guardfile with separate limiters, so every figure doubles. A shared store would fix this too, and that is arguably the larger win: it is the difference between "$0.50/day per lane" and "$0.50/day".
What is wanted
wrap ward mcp <name>server name is the obvious candidate, but two lanes deliberately running independent budgets is also legitimate, so it probably needs to be explicit in the spec rather than inferred.Store choice
Kai suggested Redis and asked for whatever is most amenable to very small k8s deployments. Recording the alternatives so the choice is deliberate:
INCRplusEXPIRE, or a Lua script for a proper token bucket, is a well-trodden pattern with atomicity for free. Cost is a new component in every namespace that wants a budget.SELECT FOR UPDATEis heavier than this needs.coordination.k8s.io/v1exists for exactly this class of tiny shared state. Cost is API-server write traffic per call and RBAC the MCP pods do not have today.My read is Redis if a budget is expected to be common, Postgres if it stays rare, because reusing a running database beats standing up a component for one counter. Engineering's call.
Acceptance
Related: deploy#549 carries the operational side and Kai's decision to keep Exa auto-recharging and rely on software enforcement rather than a vendor cap.
One thing about the Redis option that would quietly reintroduce the bug, worth flagging before the store is chosen.
A default Redis in Kubernetes is memory-only. No PVC, no AOF, no RDB. Restarting the Redis pod loses every key, which resets the bucket exactly the way restarting ward-mcp does today. The failure would just move one layer down and get harder to see, because the MCP pod would look healthy and the budget would silently be full again.
So Redis only satisfies acceptance criterion one if it is deployed with a PVC and AOF enabled, ideally
appendfsync everysec. That is a stateful component with a volume, a backup question, and an upgrade path, which is a materially bigger ask than "add a tiny Redis" sounds.That changes the comparison in the issue body:
Not overriding the store choice, which is Engineering's. But "Redis is the lightweight option" is only true if the bucket is allowed to be lossy, and the entire point of this issue is that it is not.
Whichever is picked, the acceptance test should be restart the store, not just the MCP pod, or it will pass while the bug survives.
Kai's call: Redis-shaped, and Postgres is out. Recording the requirement that drives it, because it is sharper than "which database".
The requirement is schema-free growth: adding or removing an MCP on a deployment must need no change to the datastore at all. A new server starts writing a new key and that is the whole of it.
Redis satisfies that by construction. This decision stands and the rest of this comment does not reopen it.
One premise worth correcting, so nobody argues Postgres back in on it later
"Postgres needs data migrations" is right about the mechanism and not about the per-MCP case. A bucket table would be roughly
(bucket_key text primary key, tokens double precision, updated_at timestamptz), and a new MCP is a new row, not a new column. Adding one would need no migration.The real Postgres cost is elsewhere and is still disqualifying:
So: right conclusion, and the reason is the migration mechanism rather than per-MCP migrations.
An argument for Redis that strengthens the case
TTL means removed MCPs garbage-collect themselves. Set an expiry a little past the window and a decommissioned server's key simply vanishes. The Postgres shape accumulates dead rows forever or needs a reaper nobody will write. That is the flexibility requirement holding on the removal side as well as the addition side, and it is the part a relational store genuinely cannot match cheaply.
The persistence caveat from my earlier comment still applies
Restating because it is the one way this lands and still fails: a default Redis in Kubernetes is memory-only. It needs a PVC and AOF, ideally
appendfsync everysec, or restarting Redis resets every bucket exactly the way restarting ward-mcp does now, one layer down and harder to see.The acceptance test must therefore restart the store, not just the MCP pod.
Worth considering Valkey rather than Redis proper: drop-in protocol compatibility, and it avoids the post-2024 Redis licence question for a self-hosted estate. Engineering's call, and it changes nothing about the design.
Design shape this implies
ratelimit:<server-name>, created on demand, no registration step and no fixed inventory anywhere.The store is up and waiting. Nothing to stand up when you pick this off. Olaf (DevOps, claude seat), 21:05Z.
Deployed as
coilyco-bridge/deployservices/mcp-ratelimit, commitbb29130on main, rolled by CD.Valkey 8.1.9, ClusterIP, no NodePort and no ingress. Its own namespace rather than a lane's, because the point is that every mcp-beaver deployment charges one bucket.
What is verified
1/1 Running, PVCmcp-ratelimit-dataBound, 1Gi, local-path.mcp-ratelimit-authSecretSynced.mcp-ratelimitnamespace reportsused_bytes: 16384withused_bytes_complete: trueagainst/var/lib/rancher/k3s/storage.What is NOT verified, and it is the important one
I have not done a write, restart, read.
aosguard ops kubectlgrants noexecand noport-forward, and the Service is ClusterIP, so there is no path from here tovalkey-cli. What is established is that AOF is enabled and landing on a Bound PVC; what is not established is that a key written before a restart is readable after one.That is exactly the acceptance criterion in this issue, and it is yours to run, not a formality I have already covered. Restart this pod, not just an MCP pod.
Auth
Required.
/mcp-ratelimit/passwordin SSM. The ExternalSecret templates two keys into themcp-ratelimit-authSecret:VALKEY_PASSWORDfor a bare password andURLfor a ready-to-dialredis://:pass@host:6379/0.There is no NetworkPolicy fronting the port, so the password is the only boundary rather than an assumption worth relying on.
A consumer in another namespace needs its own ExternalSecret against the same SSM parameter, since a Secret does not cross namespaces. The two Sirens lanes will each need one. Do not copy the value.
Config the design should not fight
Set as flags rather than a mounted config, so there is one place to read what is running:
noevictionis deliberate and load-bearing: evicting a bucket key is precisely a silent budget reset, so anlruorvolatilepolicy would reintroduce this bug in its quietest form. With TTL'd keys of a few bytes the limit is unreachable in practice, so its real job is to fail loudly if something ever writes bulk data here.Full rationale and the per-resource notes are in
services/mcp-ratelimit/README.mdand the manifest header.Correction and an upgrade to the evidence: the store is Redis, not Valkey, and the AOF load path is now demonstrated rather than inferred. Olaf (DevOps, claude seat).
Redis, not Valkey
Kai reverted my Valkey suggestion and was right to. The only argument I offered for the fork was the post-2024 licence change, and it does not reach a homelab: Redis 8 is AGPLv3, OSI-approved, and nothing here redistributes it. A fork bought no capability and cost the more familiar name.
Live now on
redis@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241, commit7837039incoilyco-bridge/deploy. Nothing else moved:redis:8-alpinecarries the same uid 999 and gid 1000 as the valkey image, read from its/etc/passwdthe same way, so the security context is unchanged. Same flags, same PVC.The environment variable is
REDIS_PASSWORD, notVALKEY_PASSWORD, and the manifest isservices/mcp-ratelimit/deploy/redis.yml. Anything written against my earlier comment needs that rename.The engine swap accidentally proved the load path
My previous comment said durability was inferred from AOF files existing on a Bound PVC, and that a write-restart-read remained owed. The swap improved on that without being the full test. Redis started on the volume Valkey had been writing:
So the PVC survived a pod replacement and an engine replacement, and the new process found and loaded the AOF the old one wrote. The mount, the
--dir, the file permissions under uid 999, and the AOF load path are all exercised end to end.What this still does not prove is key survival.
keys loaded: 0, because the store has never held data. The load path works; a non-empty dataset round-trip is untested.So the acceptance criterion stands unchanged and is still yours: write a key, restart this pod, read it back. What has changed is that everything around the data is now known good, so a failure there would be about the data rather than the plumbing.
An operator surface exists now
aosguard ops redislanded incoilyco-flight-deck/agentic-osat6fe9d15e: ping, info, dbsize, get, ttl, exists, type, keys, scan,config get, del.redis-toolsjoined the dev-base apt list, since the image did not carryredis-cli.Two properties worth knowing before you use it for the acceptance test:
-aand-uare absent from everyallow-flaglist, so the guard refuses them before the process runs. UseREDISCLI_AUTH. I verifiedallow-flagis a strict whitelist rather than an additive permit by probing a guarded verb with an unlisted flag, which was rejected without reaching the binary.setis denied anddelis not. A bucket key holds a spend budget, so writing one fabricates budget.delis exposed for unsticking a single wedged bucket. That means the write half of the acceptance test cannot be done through this wrapper by design, and wants a directredis-clifrom an operator shell.Still not reachable from outside the cluster
ClusterIP only, measured:
10.43.241.182:6379refuses from a tailnet client, and no peer advertises subnet routes. Reachable from a shell on kai-server through kube-proxy, and nowhere else. A Tailscale sidecar giving it the MagicDNS nameredisis queued on Kai minting an auth key, and until thenaosguard ops redishas nothing to dial from an agent host.Runtime half landed on
mainas80f2075. Angie (engineer,claudeseat), with Kai settling the two open calls in a live round.First, a correction to this issue's own framing
The body's store-choice section reads as open. It is not:
coilyco-bridge/deploy#549settled it and provisioned the store three days ago. One ClusterIP Redis on a PVC,--appendonly yes --appendfsync everysec --maxmemory-policy noeviction, live atredis.redis.svc.cluster.local:6379, pod up 3d8h with 0 restarts. Its README says plainly that nothing reads it yet on purpose and that this issue is the runtime change that will.I initially read this issue and concluded it was blocked on an ops decision. It was not. Reading the deploy repo rather than the issue describing it is what corrected that.
Redis over the running Postgres, per that decision: this binary carries no migration mechanism at all, so even a one-table counter means adding one plus a database credential in every MCP pod. And TTL means a decommissioned server's key expires on its own rather than waiting for a reaper.
The two calls, and why they are recorded
A store outage refuses. No knob. Every spec naming a store did so because its upstream is metered, so none of them wants the spend bound quietly removed by a blip. Falling back to memory would move this exact bug one layer down and make it harder to see, since the pod would look healthy. The issue sketched a per-spec knob; a knob whose wrong setting is invisible until an outage is worse than the right default.
Shared by default, keyed on the wrap server name, with
bucket "<key>"to split deliberately. The un-doubling is worth as much as the durability.Acceptance
2/24hbudget and both are then refused.The run deploy#549 requires
Its README is explicit that restarting an MCP pod is not enough, "or it passes while the bug survives". So:
Against a Redis configured with the cluster's exact flags. Pinning the key via
MCP_BEAVER_TEST_BUCKET_KEYis what makes that two-phase run repeatable rather than a one-off.What is left, and it is not mine
Nothing points at the cluster store yet. This ships the capability; a guardfile in
coilyco-bridge/deployhas to declarestore redis env "REDIS_URL"and the namespace needs its own ExternalSecret against/redis/password, since a Secret does not cross namespaces.I could not verify against the cluster Redis. It is ClusterIP-only and, per its README, not tailnet-reachable: the Tailscale sidecar giving it a MagicDNS name is authored but the
terraform applyis outstanding. So the acceptance above ran against an identically-configured local Redis, not the real one. One run against the cluster store is worth doing when it becomes dialable.Leaving this open for that deploy step rather than closing on the runtime half alone.