Runtime config: the plan
Runtime config describes the overlay as it ships:
a mutable layer over servers:, written from /connectors or over
/mcp, live on the next request. This page is about the distance between that
and the end state somebody actually asked for —
all MCP configs stored in a runtime database, so we can manage them through
/connectorswithout redeploys, and all secrets are references to the secret store
— stated as phases, with what closes each one, and with the steps a human has to take pulled out into their own section, because “can this be done autonomously?” is a question the plan owes an answer to rather than a footnote.
Where the coverage actually is
Section titled “Where the coverage actually is”Measured against the production roster — strad/infra/strad.prod.yaml in the
private tadasant/tadasant-internal, on 2026-09-12 — so these counts are a dated
snapshot this repo cannot re-derive. The numbers matter more than the
impression: “the mechanism exists” and “the mechanism covers the fleet” are
different claims, and on that date only the first was true.
| count | |
|---|---|
| server entries | 45 |
— supplementary-image | 37 |
— remote-http | 6 |
— local-tunnel | 1 |
— builtin | 1 |
entries declaring env: | 26 |
entries declaring params: true | 5 |
env: values that are ${NAME} refs | 42 of 52 |
headers: values that are ${NAME} refs | 8 of 8 |
And the number this page exists to move — how many of the 45 a runtime write can create:
| count | |
|---|---|
remote-http + local-tunnel — unhosted, fully runtime-manageable | 7 |
supplementary-image declaring no deploy-only field | 8 |
supplementary-image declaring one or more | 29 |
builtin — never creatable at runtime, by construction | 1 |
15 of 45. The deploy-only fields are env:, params:, paramsWritable:,
paramsRefresh:, consoleEnv:, consoleStores: and mcpStores:, and
assertCreatable refuses every one of them on a runtime write because they
reach a container as environment variables the renderer bakes at render time.
A process’s environment is fixed when it spawns.
The 8 header values being refs already is the other half of the picture, and a happier one: correct practice is universal here, which is what made phase 1 a description of existing practice rather than a migration.
The four obstacles, and which are real
Section titled “The four obstacles, and which are real”env:blocks runtime management, and it is the big one. Real, and it is physics rather than policy — see phase 3. 29 of 45 slugs are behind it.- The overlay is
servers:only. Real, and sharper than it looks. Entitlement is a two-sided intersection (src/gateway/entitlements.ts): aroles:entry names servers, a server’sentitlements:names roles, and both must agree. The overlay carriesentitlements:because that lives on a server entry. It does not carryroles:. A role that names its slugs explicitly rather than*therefore cannot reach a connector created at runtime until a deploy editsroles:— however itsentitlements:are written — and production’s narrower roles are written that way. See phase 4. - A runtime-created connector must never hold a secret value. Real, and shipped — phase 1.
envFallback: truestill permits a${NAME}to resolve from the process environment. Real, and the smallest of the four — read the two-fallbacks note in the internalstrad/infra/README.mdbefore proposing the flip. Undergcp-parameter-storethe renderer stops baking the gateway-namespace names into the spec at all, so the environment the knob falls back to is empty for every one of them. Flipping it tofalsecuts off nine names that have nothing to fall back to and is a no-op for the twenty-three that migrate on a separate, render-time mechanism the knob does not control. It is a tidying-up, not an unblocking, and it belongs last.
A fifth thing is not on that list and belongs on this one.
- Reaching the write tools was worth more than it looked. The fields a
runtime write may still move on an entry — a new slug’s
path:, and fields the access-control rules did not bind on a checked-in one — let a writer reach credentials that belong to other slugs. The mechanisms and their reproductions are tracked in the private strad#349 rather than on this page. They were bounded only by the one thing outside the roster — theconnectorsslug beingadmin-only — and would have stopped being bounded the moment that changed. Phase 2 bounds them inside strad instead, with one half of the question left open on purpose.
The phases
Section titled “The phases”Each phase names what changes, who owns it, and what evidence closes it. The order is not negotiable in one place: phase 2 gates phase 4, because widening what a runtime write may move before bounding what it is worth makes the blast radius larger rather than smaller.
Phase 1 — a runtime write names a credential, never carries one (shipped)
Section titled “Phase 1 — a runtime write names a credential, never carries one (shipped)”What changed. src/config/secret-position.ts: every credential-carrying
value in an entry a runtime write would store must be a reference — one
${NAME}, optionally after an auth scheme. It covers headers: on every kind,
url:’s userinfo, and oauthRefresh:’s grant. Enforced at
RuntimeConfig.assertAcceptable (the entry a caller composed) and again at
RuntimeConfig.apply (everything a write would newly store, which is what
catches toggle and set_connector_enabled re-upserting an entry they did not
compose). Both doors inherit it from the one seam they already share.
Owner. strad — this repo.
Evidence. test/config.secret-position.test.ts, plus a door-level refusal in
test/connectors.mcp.test.ts and in test/connectors.hot-reload.test.ts. The
overlay parameter is created with secret: false, which is why this is about
storage at rest and not only about tidiness.
What it does not cover, stated rather than implied away: a URL query
parameter, and a builtin’s options:. Both in
Known limitations #96, both closed by phase 6.
Phase 2 — bound what the write tools are worth (shipped, with one half left open)
Section titled “Phase 2 — bound what the write tools are worth (shipped, with one half left open)”The connectors write tools let a writer reach mounts and credentials that
belong to slugs other than the one being written. strad#349 has the mechanisms,
verified. The expectation on this page was that every candidate bound trades
a workflow the roster exists to serve — re-pointing a quick tunnel without a
deploy, adding a second slug onto an already-deployed mount — so that none could
be picked without a posture decision. Measured against the production roster,
that turned out to be true of one candidate and false of the others.
What changed. RuntimeConfig.assertAcceptable takes the writing
principal, supplied by both doors, and assertWithinReach bounds a write by
what its author already reaches:
- A new
supplementary-imageslug may only land on abundle:+path:its author reaches through a checked-in slug. - A credential destination on a checked-in slug may only be moved by an
author that slug’s
entitlements:admit — which is what re-pointing one’s own quick tunnel needs. There are four such fields rather than the one #349’s comment names:url:,oauthRefresh.tokenUrl, and a connector’soauth.tokenUrl/oauth.authorizeUrl. The OAuth endpoints carry a better credential than the bearer they mint — the client secret and the long-lived refresh token — andoauth:is declared on every kind, so the bound is not scoped to the unhosted ones.
Alongside them, the slug’s upstream identity — kind:, and a supplementary
bundle: and path: — is now bound on both write paths rather than only on the
edit path. That closes a third mechanism, found while bounding the first two and
the only one of the three that lands against production’s shape: a narrow role
reaches exactly the slugs its roles: entry NAMES and the overlay cannot write
roles:, so a create under a new name is unreachable to it — but removing a
checked-in local-tunnel and re-creating it as a supplementary-image on
another mount keeps the name, and with it the reach the deploy granted.
Why these needed no choice. Each candidate was measured against the roster
rather than argued about. The two issue candidates failed: “may not carry roles
beyond the principal’s” does not stop the attack at all (a zimmer writer
granting [zimmer] never exceeded its roles), and “may not land on a path: a
checked-in slug declares” forbids the gmail-ro / gmail-rw create outright.
The reach bound costs nothing instead of costing a workflow, because a role
written servers: ["*"] whose slugs all name it reaches every mount the
deployment declares — every one of the 27 distinct bundle: + path: mounts
the production roster declared on 2026-09-13, counted with the same caveat as the
table at the top of this page: a dated read of strad/infra/strad.prod.yaml in
the private tadasant/tadasant-internal, which this repo cannot re-derive. A
mount is not a slug — prod declares more slugs than mounts, which is what a
-ro / -rw pair on one path is. So the bound never fires for the role that
holds the write tools today. The identity freeze costs nothing because all three fields
were already immutable on the edit path; it makes a move refused as one call
refused as two.
The half that was left a choice: carrying the tools: policy along. The
bounds are measured in mounts, so a writer that already reaches a mount may
create a second slug on it with a wider tool surface than the checked-in slug it
reaches that mount through. Closing that means a created slug may serve no more
than its author already reaches on that mount, and that one does trade a
workflow, because it narrows the wildcard role too. Three of those 27 mounts are
reachable by it only through a tools.allow slug — the three
google-sheets-*-ro mounts, which are prod-only and are in neither this repo’s
staging config nor anything else a reader here can check — and on those it could
no longer create the wider half of a -ro / -rw pair without a deploy.
Decided on 2026-09-13: the half stays open. Tadas, given that trade: “i want
to keep the workflow you mentioned for now”. A runtime write may still create a
wider-tool slug on a mount its author already reaches, and the -ro / -rw
runtime workflow is kept. “For now” is part of the decision: it is a present
choice about a present roster.
What reopens it: a connectors write tool granted to a role other than
admin. What bounds the residual today is who holds the write tools rather
than what they may do with them. save_connector, set_connector_enabled,
restore_connector and remove_connector are admin-only in production, and
admin already reaches every mount the deployment declares, so the only
widening available through them is the -ro / -rw workflow the decision keeps.
Production gives the zimmer role the read half alone, as a connectors-ro
slug (tadasant-internal#2816). Grant any of those four to a role narrower than
admin and the escalation the bound would have closed becomes reachable: a role
the deploy gave read-only tools on a container can hand itself the write ones.
The trigger is drawn wider than the mechanism deliberately. The widening itself
runs through save_connector alone, because it is the only one of the four that
composes an entry and so the only one that reaches assertAcceptable at all —
but the other three carry the availability residual described in
Known limitations #96, and a grant is decided as a grant rather
than tool by tool. So any of the four re-opens this, in the PR that proposes it.
Human step 1 decides the grant; this is the
question that travels with it. Both prod facts here are a dated read of
strad/infra/strad.prod.yaml in the private tadasant/tadasant-internal, like
the counts above, and this repo cannot re-derive either.
Owner. strad — this repo.
Evidence. test/connectors.isolation.test.ts carries one suite per mechanism
in the shape the existing three bounds use — the attack, the bound, the
legitimate case that must keep working — including an end-to-end probe showing a
re-pointed slug’s credential arriving at a sink, and the admin cases asserted
as still passing. The principal is proved to survive each door end to end rather
than asserted: test/connectors.mcp.test.ts drives a narrow-role token through a
real MCP client, test/connectors.hot-reload.test.ts drives a narrow-role console
session through a real form post, and both fail if either door stops passing it.
The chosen behaviour of the open half is recorded as a test, so that re-taking
the decision must update the record.
Phase 3 — a mount reads its own credentials instead of receiving them
Section titled “Phase 3 — a mount reads its own credentials instead of receiving them”The 29. A supplementary-image mount’s credentials arrive as environment
variables baked into the app spec, and the bundle host discovers which extra
slugs to mount by looking for prefixed marker variables in its own
process.env (servers/bundle/host/src/mounts.ts). So a slug whose variables
are not there is a slug that container does not serve, whatever the roster says.
The route is the one paramsRefresh: already takes, widened: the host reads
from the store rather than from its environment — a name the spec never listed,
not merely a value behind a name it did. src/secrets/param-route.ts is the
existing wire between core and the host and is already a vendored seam.
strad#343 carries it.
Honest answer to “can supplementary-image get there at all?” Yes, but not
uniformly. A mount that reads its credential from process.env once at
start-up — which is most third-party MCP server images — cannot be given a new
one without a restart, and nothing in App Platform restarts one container. So
phase 3 delivers “a new account for a bundled server, live after the next
container restart”, not “live on the next request”. The kinds that are genuinely
add-and-use stay remote-http and local-tunnel, which run in core’s own
process; for a third-party server that is not published as a hosted endpoint,
the honest end state is a mount whose credential set is refreshed on a schedule
or on a restart, and the plan should say that to whoever is expecting otherwise.
What changed — the strad half. A store-served instance: a
supplementary-image entry on path: /_instance/{tree}/{its own slug} with
params: true. Core lists the instances on a component
(GET /_strad/instances?bundle=) from its current config, and serves one
instance’s own namespace (GET /_strad/instances/{slug}). The bundle host builds
the instance from what it is served, re-syncs on an interval and on the first
request for an instance it does not hold, and never falls back to its own
environment. The render bakes nothing for one. assertCreatable accepts
params: true on an instance and still refuses the other six deploy-only fields;
the reach bound accepts an instance on its own path when the author reaches some
checked-in slug on that component. The client and the reconciler are in
src/secrets/param-route.ts, which is already vendored.
Which mounts get which.
telegram,google-sheets,grafana— add and use, no restart. Each already builds from an injected environment, so an instance is a second copy in the same process, and a rotation rebuilds it on the next sync.remote-filesystem-tmp-public— per-slug by environment, and not an instance tree yet: its upload and download routes key on the mount path, and its variable names carry the second slug rule strad#153 is about.- gmail, calendar, docs, and every tree that reads
process.envonce — still a deploy. Nothing restarts one App Platform container, and a second credential for such a tree needs a second process. The honest end state above stands.
strad#153 was checked first, as the issue gate asked. An instance’s namespace
is addressed by the slug through serverParameterPath, and its values reach the
tree under their bare names. No <SLUG>__NAME mangling is on this path, so
neither of the two namespacing rules #153 describes is either.
What it does not decide. The open half of phase 2 is unchanged: an instance
is a new mount reached through no checked-in slug, so there is no narrower
checked-in tools: for it to widen past, and a writer that reaches an existing
mount reaches it exactly as before. There is no new secret-reading principal:
core reads the namespace with the resolver it already holds, and hands the
values only to the component the slug is on, identified by that component’s own
internal token (Known limitations #63, #100).
Owner. strad for core and the host tree; strad-production for the prod
roster once a slug can migrate.
Evidence. In this repo, test/instances.test.ts: a slug created through
RuntimeConfig is listed by core, served from its own namespace (never core’s
environment, never the gateway namespace), rebuilt on a rotation and removed on
a removal, with no deploy — plus the route refusals, the relaxed
assertCreatable, the reach bound and the schema rules. Still owed by
strad-production: one prod slug created end to end at runtime, serving tools,
with no entry in infra/strad.prod.yaml.
Phase 4 — roles: becomes part of the overlay
Section titled “Phase 4 — roles: becomes part of the overlay”Granting or revoking a role’s reach takes a PR and a deploy, and this is the
wall that gets hit most often. It is small in code and large in consequence:
roles: is one half of the entitlement intersection, so an overlay that can
write it is an overlay that can grant access.
The shape that keeps it reviewable: a second overlay document, with its own
parameter, its own console page and its own tool surface — not a widening of
SERVERS. A grant is then a thing with its own revision history and its own
audit line, and a deployment can mount the roster editor without mounting the
grant editor. assertNoAccessControlChange’s argument still holds on the other
side: a role the checked-in file declares is the deploy’s, and what the overlay
adds is grants, not redefinitions.
Was blocked on phase 2, which has shipped its bounds: a write now reaches no
mount its author does not already reach, so the write tools cost less than admin
and a grant editor added to them is worth something narrower than everything.
Read the residual phase 2
leaves open before designing this one, because a grant editor changes the roles
side of the intersection and the reach bound is measured from it — a phase where
roles: is writable is a phase where “what its author already reaches” is a
thing the author can move.
Owner. strad.
Evidence. A role granted reach to a slug from /connectors and from
save_grant, a /mcp request that is refused before it and served after it,
and no deploy in between.
Phase 5 — the rest of the document
Section titled “Phase 5 — the rest of the document”bundles:, auth: and the gateway: block. Each one is a different answer, and
lumping them together is how this gets done wrong:
bundles:decides container topology. A runtime write cannot create a component; it can only name one that exists. Read-only at runtime is the right end state, and saying so is the deliverable.auth:already moved the part that should move —auth.staticTokens.store: parameter-storeputs the/mcptoken records in the store, so/tokenscan mint and revoke without a deploy. What is left is the enable/disable flags, which are posture, not operations.gateway:is boot shape —publicUrl, telemetry, the secrets provider. Changing it at runtime would mean a process that no longer matches the spec that started it. Read-only, deliberately.
Owner. strad.
Evidence. Each of the three has a paragraph in
Runtime config saying which it is and why, and
servers/interface.md agrees.
Phase 6 — a typed statement of which fields carry a credential
Section titled “Phase 6 — a typed statement of which fields carry a credential”The two positions phase 1 does not cover — a URL query parameter, a builtin’s
options: — need something phase 1 deliberately did not invent: a per-kind,
per-builtin declaration of which fields are credential-carrying. With one,
assertSecretsByReference stops being a list of positions in a module and
becomes a property of the schema, and redacted() in src/servers/connectors.ts
stops being a best-effort pass over two known fields.
Owner. strad.
Evidence. A builtin declaring a credential-carrying option, refused as a
literal on the runtime path and redacted on the read path, with echo’s
greeting still accepted as the literal it is.
Phase 7 — envFallback: false
Section titled “Phase 7 — envFallback: false”Last, and small. The knob governs the nine gateway-namespace names, not the twenty-three supplementary ones; the twenty-three have a separate render-time fallback it does not control. Flipping it removes a backstop that is already empty under this provider. It is worth doing for the honesty of the config, and it unblocks nothing.
Owner. strad-production — it is one line in infra/strad.prod.yaml.
Evidence. The deploy’s own parameter-store report shows every one of the
nine present before the flip, and /healthz is not degraded after it.
What only a human can do
Section titled “What only a human can do”The answer to “will I need to jump in?” is yes, in five places — one of them already answered — and none of them is “review the code”. Each is named with the reason, because the reason is what decides whether it can ever be automated away. Item 2 stays listed after its answer because that answer carries a condition that brings the question back.
-
Decide what the
connectorswrite tools may be granted to (phase 2). This is a posture decision, not a defect: strad#349’s three options — read-tools-only for the narrow role, keep the write tools admin-only, or bound creates in strad — are a trade between capability and blast radius, and an agent picking one of them is an agent choosing its own privileges. The grant that raised it landed on the read half: thezimmerrole reaches theconnectorsslug as aconnectors-roslug, the read tools alone (tadasant-internal#2816). Whether a later grant includes the write half is still exactly this decision, and item 2 comes with it. -
Decide whether a runtime write may widen a mount’s tool surface — the half of phase 2 that is left open. Answered on 2026-09-13 by Tadas: “i want to keep the workflow you mentioned for now”. The bounds that shipped cost no workflow, so they needed no decision and did not get one. This one did: forbidding a created slug from serving more than its author already reaches on that mount closes a writer widening its own read-only access to a container, and takes the wider half of a
-ro/-rwpair out of the runtime roster on any mount whose only checked-in slug is the narrow one, which is three of production’s 27 mounts. Adding the read-write half on those would then need a deploy. The answer keeps that workflow, so the widening stays.It comes back with item 1, and only with it. The residual is bounded by who holds the write tools:
save_connector,set_connector_enabled,restore_connectorandremove_connectorareadmin-only in production, and thezimmerrole holds the read half alone (tadasant-internal#2816). Granting any of the four to a role narrower thanadminis what makes the trade live again — the widening itself runs throughsave_connector, the only one of the four that composes an entry, and the trigger is drawn at the grant because that is the unit the decision is taken in. The PR proposing such a grant is where this gets re-taken rather than assumed settled. -
Grant the IAM the host-side credential path needs (phase 3). Reading a parameter from a container that holds no cloud credential means core serving it a value over the private network, and whatever principal ends up dereferencing a secret parameter needs
roles/parametermanager.parameterAccessor. That role is the one thing standing between/mcpand a production secret value, so who holds it is a decision only its owner can make. An agent can write the Terraform; it cannot decide that a new principal may deref secrets.As shipped, phase 3 needs no new principal: core serves an instance’s values out of the resolver it already holds, which already has
parameterAccessor. Per-component internal tokens came first, as that decision required: core serves an instance’s values only to the component that runs it (Known limitations #63, #100). -
Complete each OAuth consent (phases 3 and 4, ongoing). Granting consent is a browser handshake with a third party.
describe_connector_authorizationexists precisely because an agent cannot do it: it returns the URL a human opens and the parameter path the credential lands at.docs/src/content/docs/operate/connectors.mdhas the flow. -
Merge every PR. The runtime-config surface is the one an escalation would arrive through, and a human decides when a change to it lands.
Worth being exact about what enforces that, because it is not GitHub. Neither repository’s
mainis protected: both are private repositories on a plan where the feature is unavailable, sobranches/mainreports"protected": falseand both the branch-protection and the rulesets APIs answer403. The gate is the convention that an agent never merges its own PR, plus the merge gate that rates one — which is a gate a human operates, not a branch rule. Treat “protected” as a description of the practice rather than of the repository.
Everything else in this plan is agent-executable: the code, the tests, the docs,
the staging verification, the prod roster edits in infra/strad.prod.yaml, and
the envFallback flip. None of it needs a shell on a running container — see
the operability rule in AGENTS.md, which this plan is bound by like any other
change here.
What belongs to strad-production
Section titled “What belongs to strad-production”This repo mints a generic, server-less image. The production roster, the pinned
image version, the prod secrets map and the deploy docs live in
tadasant/tadasant-internal under strad/, and that directory belongs to the
strad-production agent root. Three things in this plan land there rather than
here:
- The
zimmergrant onconnectors— with thetools:decision from above applied, rather than the full surface by default. - Migrating a slug off
env:now that phase 3’sstradhalf has landed: for a telegram, google-sheets or grafana slug, moving it to its instance path withparams: true, seeding its namespace, and removing theenv:block from its entry ininfra/strad.prod.yaml. envFallback: false(phase 7), after the nine names are confirmed present.