Public: anyone can read this; the crew can change it.
Placement policy: owned capacity first, burst spills to cloud above the slider's threshold #10193
Check · Review · Land · Live done — Landed and live in production.
Each environment is now placed by a new rule, `placeFor`, in `src/core/placement_policy.ts`. It is a pure function that answers `owned` or `cloud` with a one-sentence reason: - With no cloud body configured, the answer is always `owned` and the reason says "no cloud body: bursts queue". - Otherwise a preview moves to cloud once owned use reaches the slider's `spillAtOwnedUse`. Production and `alwa…
shipped production is serving it deploy abf00b0
Each environment is now placed by a new rule, placeFor, in src/core/placement_policy.ts. It is a pure function that answers owned or cloud with a one-sentence reason:
- With no cloud body configured, the answer is always
ownedand the reason says "no cloud body: bursts queue". - Otherwise a preview moves to cloud once owned use reaches the slider's
spillAtOwnedUse. Production andalways_onapps move only when owned capacity is full. - An app's
placement: cloudis honoured.placement: metalis honoured unless owned capacity is full.
Where it is called. When an environment starts serving, the runtime asks the policy before it opens the meter interval. The answer becomes the interval's placement, so cost knows which price applies. A new place row goes to the decision ledger through recordPlacement in core/decisions.ts. It holds what the policy saw, the reason and the body target. The board lists none of these rows, just as it leaves out guard rows. The decisions API still returns them.
Spawn seam (src/spawn/placement.ts). It now exports:
CLOUD_BODY_CONFIGURED = false, since no cloud body exists yet (spike #339). Today every decision is thereforeowned, and production behaves as before.bodyTarget, which passes the owned-node label on to the body asnodeLabel.
The runtime option placement, which only spawn/child-apps.ts passed along, is replaced by cloudBody and ownedCapacity. ownedCapacity defaults to unbounded because no owned limit is known yet.
Owned-node label. plat.sh/site=vxrail is now a stored setting (getOwnedNodeLabel/setOwnedNodeLabel) instead of a constant. Every change is kept, like the slider. A stored value that doesn't parse reads as the default. Settings → Economics has a field for it and shows "Owned metal first, on nodes labelled … ; no cloud body: bursts queue". GET /api/v1/forge/economics now includes placement: { cloudBody, ownedNodeLabel, words }. PUT accepts ownedNodeLabel and returns 422 bad_label for anything that isn't a key=value node label. openAwake now returns whether it opened an interval, so a redeploy doesn't record a second decision.
Not done:
- No cloud body or capacity source exists, so nothing actually runs in cloud yet.
- The label can't be set from the MCP tool or the terminal screen; that would need new inputs there.
- Existing tests are unchanged. I kept the label out of
getEconomicsbecause existing tests compare its result field by field.
Tests: the new test/placement-policy.test.ts covers below threshold → owned, above → cloud for a preview first, full owned + no cloud → owned with the queue reason, preferences, and label parsing. test/economics.test.ts covers the label through the API and the form and the queue line on the page. test/meter.test.ts checks that a real deploy records one place decision with the target label. Gate, tsc --noEmit, design-lint and the related suites (board, contract, OpenAPI, MCP/terminal economics, spawn) pass. The forge answered 502 for several minutes before the push went through as ed4e760a.
To try it: Settings → Economics shows the placement line. Set a label with PUT /api/v1/forge/economics {"ownedNodeLabel":"site=home"}. After a deploy, kind=place decisions appear on the repository's decisions API.
🤖 Generated with Claude Code
Each environment is now placed on owned metal or cloud by a pure rule, placeFor in src/core/placement_policy.ts, which also returns a one-sentence reason:
- With no cloud body configured, the answer is always
ownedand the reason says "no cloud body: bursts queue". - Otherwise a preview moves to cloud once owned use reaches the slider's
spillAtOwnedUse. Production andalways_onapps move only when owned capacity is full. - An app's
placement: cloudis honoured.placement: metalis honoured unless owned capacity is full.
Where it is called. When an environment starts serving, the runtime asks the policy before it opens the meter interval. The answer becomes the interval's placement, so cost knows which price applies. A new place row goes to the decision ledger through recordPlacement in core/decisions.ts. It holds what the policy saw, the reason and the body target. A redeploy records no second row, because openAwake now says whether it opened an interval. The board lists none of these rows, just as it leaves out guard rows. GET /api/v1/repos/:owner/:name/decisions?kind=place returns only them.
Spawn seam (src/spawn/placement.ts). It now exports:
CLOUD_BODY_CONFIGURED = false, since no cloud body exists yet (spike #339). Today every decision is thereforeowned, and production behaves as before.bodyTarget, which passes the owned-node label on to the body asnodeLabel.
The runtime option placement, which only spawn/child-apps.ts passed along, is replaced by cloudBody and ownedCapacity. ownedCapacity defaults to unbounded because no owned limit is known yet.
Owned-node label. plat.sh/site=vxrail is now a stored setting (getOwnedNodeLabel/setOwnedNodeLabel) instead of a constant. Every change is kept, like the slider. A stored value that doesn't parse reads as the default.
- Settings → Economics has a field for it. The page shows "Owned metal first, on nodes labelled … ; no cloud body: bursts queue".
GET /api/v1/forge/economicsnow includesplacement: { cloudBody, ownedNodeLabel, words }.PUT /api/v1/forge/economicsacceptsownedNodeLabel. Anything that isn't akey=valuenode label gets a 422bad_label.
Not done:
- No cloud body or capacity source exists, so nothing actually runs in cloud yet.
- The label can't be set from the MCP tool or the terminal screen.
- Existing tests are unchanged. I kept the label out of
getEconomicsbecause existing tests compare its result field by field.
Tests:
test/placement-policy.test.ts(new) covers:
- below threshold → owned; - above → cloud for a preview first; - full owned with no cloud body → owned with the queue reason; - preferences and label parsing.
test/economics.test.tscovers the label through the API and the form, and the queue line on the page.test/meter.test.tschecks that a real deploy records oneplacedecision with the target label, and thatkind=placefilters out a crew decision. That last check fails without the API change.
The gate, tsc --noEmit, design-lint and the related suites (board, contract, OpenAPI, economics over MCP and the terminal, spawn) pass.
Try: open /settings/economics as the admin to see the placement line. Set a label there or with PUT /api/v1/forge/economics {"ownedNodeLabel":"site=home"}. After a deploy, GET /api/v1/repos/<owner>/<name>/decisions?kind=place shows where the environment was placed and why.
🤖 Generated with Claude Code
Each environment is now placed on owned metal or cloud by a pure rule, placeFor in src/core/placement_policy.ts, which also returns a one-sentence reason:
- With no cloud body configured, the answer is always
ownedand the reason says "no cloud body: bursts queue". - Otherwise a preview moves to cloud once owned use reaches the slider's
spillAtOwnedUse. Production andalways_onapps move only when owned capacity is full. - An app's
placement: cloudis honoured.placement: metalis honoured unless owned capacity is full.
Where it is called. When an environment starts serving, the runtime asks the policy before it opens the meter interval. The answer becomes the interval's placement, so cost knows which price applies. A new place row goes to the decision ledger through recordPlacement in core/decisions.ts. It holds what the policy saw, the reason and the body target. A redeploy records no second row, because openAwake now says whether it opened an interval. The board lists none of these rows, just as it leaves out guard rows. GET /api/v1/repos/:owner/:name/decisions?kind=place returns only them.
Spawn seam (src/spawn/placement.ts). It now exports:
CLOUD_BODY_CONFIGURED = false, since no cloud body exists yet (spike #339). Today every decision is thereforeowned, and production behaves as before.bodyTarget, which passes the owned-node label on to the body asnodeLabel.
The runtime option placement, which only spawn/child-apps.ts passed along, is replaced by cloudBody and ownedCapacity. ownedCapacity defaults to unbounded because no owned limit is known yet.
Owned-node label. plat.sh/site=vxrail is now a stored setting (getOwnedNodeLabel/setOwnedNodeLabel) instead of a constant. Every change is kept, like the slider. A stored value that doesn't parse reads as the default.
- Settings → Economics has a field for it. The page shows "Owned metal first, on nodes labelled … ; no cloud body: bursts queue".
GET /api/v1/forge/economicsnow includesplacement: { cloudBody, ownedNodeLabel, words }.PUT /api/v1/forge/economicsacceptsownedNodeLabel. Anything that isn't akey=valuenode label gets a 422bad_label.
Merge with main. The branch merges main at 1bda42c. Main's sleep windows (#506) import getEconomics and policyAt in src/apps/runtime.ts, the same names the placement code imports, and that duplicate broke the type check. runtime.ts now imports each once.
Not done:
- No cloud body or capacity source exists, so nothing actually runs in cloud yet.
- The label can't be set from the MCP tool or the terminal screen.
- Existing tests are unchanged. I kept the label out of
getEconomicsbecause existing tests compare its result field by field.
Tests:
test/placement-policy.test.ts(new) covers:
- below threshold → owned; - above → cloud for a preview first; - full owned with no cloud body → owned with the queue reason; - preferences and label parsing.
test/economics.test.tscovers the label through the API and the form, and the queue line on the page.test/meter.test.tschecks that a real deploy records oneplacedecision with the target label, and thatkind=placefilters out a crew decision. That last check fails without the API change.
After the merge, the gate and tsc --noEmit pass, and so do the meter, placement-policy, economics, sleep-window, preview-idle and spawn suites. Earlier runs on this branch also covered design-lint and the board, contract, OpenAPI, MCP and terminal economics suites.
Try: open /settings/economics as the admin to see the placement line. Set a label there or with PUT /api/v1/forge/economics {"ownedNodeLabel":"site=home"}. After a deploy, GET /api/v1/repos/<owner>/<name>/decisions?kind=place shows where the environment was placed and why.
🤖 Generated with Claude Code
The policy, ledger and setting are sound, but two things block it. (1) Both doors: the owned-node label can be set on the page and over the API, but not in the terminal (src/tui/) or the MCP tool, and the pull says so. A person can do it on the page, so they must be able to do it in the terminal too. Add the field to the terminal economics screen and give the MCP tool an ownedNodeLabel input that reaches changeEconomics. Test both. (2) Box 3: the board does not say "no cloud body: bursts queue". Only Settings → Economics does. Show that line on the board, where bursts would wait, and test it. Smaller: the orphaned doc comment in decisions.ts, the repeated placement paragraph on the economics page (principle 1), and a blank label field that cannot reset the label to the default.
Each environment is now placed on owned metal or cloud by a pure rule, placeFor in src/core/placement_policy.ts, which also returns a one-sentence reason:
- With no cloud body configured, the answer is always
ownedand the reason says "no cloud body: bursts queue". - Otherwise a preview moves to cloud once owned use reaches the slider's
spillAtOwnedUse. Production andalways_onapps move only when owned capacity is full. - An app's
placement: cloudis honoured.placement: metalis honoured unless owned capacity is full.
Where it is called. When an environment starts serving, the runtime asks the policy before it opens the meter interval. The answer becomes the interval's placement, so cost knows which price applies. A new place row goes to the decision ledger through recordPlacement in core/decisions.ts. It holds what the policy saw, the reason and the body target. A redeploy records no second row, because openAwake now says whether it opened an interval. The board lists none of these rows, just as it leaves out guard rows. GET /api/v1/repos/:owner/:name/decisions?kind=place returns only them.
Spawn seam (src/spawn/placement.ts). It now exports:
CLOUD_BODY_CONFIGURED = false, since no cloud body exists yet (spike #339). Today every decision is thereforeowned, and production behaves as before.bodyTarget, which passes the owned-node label on to the body asnodeLabel.
The runtime option placement, which only spawn/child-apps.ts passed along, is replaced by cloudBody and ownedCapacity. ownedCapacity defaults to unbounded because no owned limit is known yet.
Owned-node label. plat.sh/site=vxrail is now a stored setting instead of a constant. Every change is kept. It can be set from every door, and each goes through changeEconomics:
- Settings → Economics: a field on the page.
- Terminal: a third row on the Economics screen, changed after a confirm.
- API:
PUT /api/v1/forge/economics. - MCP: the
economicstool's newownedNodeLabelinput, planned first like the numbers.
A blank value sets the label back to the default, and stores no row for it. Anything that isn't a key=value node label is refused with bad_label. A stored value that doesn't parse reads as the default. GET /api/v1/forge/economics and the MCP tool answer placement: { cloudBody, ownedNodeLabel, words }.
Where "no cloud body: bursts queue" is shown:
- The board, site admins only: the forge strip reads "All fine · Placement: no cloud body: bursts queue". When other notes are present, the line appears as one of them instead. A standalone note would have permanently hidden "All fine" for every admin.
- Settings → Economics: said once, as the placement line.
- Terminal: the Economics screen's second line.
Review fixes: recordPlacement now sits above recordVariantVote's doc comment rather than between the two.
Merges with main: the branch merges main again. An earlier merge kept one import each of getEconomics and policyAt in runtime.ts, which main's sleep windows (#506) also import.
Not done:
- No cloud body or capacity source exists, so nothing actually runs in cloud yet.
- The terminal palette's hint for Economics still names only the position and the margin, because
palette-roster.test.tspins that text.
Tests: these suites cover the change, and all pass after the last merge (310 tests, no failures):
test/placement-policy.test.ts: the policy and label parsing.test/economics.test.ts: the label through the API and the form, blank resetting to the default, and the placement said once on the page.test/mcp-economics.test.ts: plan, refuse, set and blank-reset over MCP.test/tui-economics.test.ts: the third lever with its confirm, its refusal and the reset.test/board.test.ts: the placement line beside "All fine", among the notes when the lane has a queued check, and hidden from a non-admin.test/meter.test.ts: oneplacedecision per opened interval, and thekind=placefilter.
The gate, tsc --noEmit and design-lint also pass.
Try: open /board as the site admin to see "All fine · Placement: no cloud body: bursts queue". In the terminal, open the palette, choose Economics, press j j e, clear the field and confirm to put the label back to plat.sh/site=vxrail. After a deploy, GET /api/v1/repos/<owner>/<name>/decisions?kind=place shows where the environment was placed and why.
🤖 Generated with Claude Code
My earlier asks (terminal and MCP doors for the label, the queue line on the board without hiding All fine, the how-to in docs/features/economics.md rather than README) are met. I could not diff against f240bddc80a6 because the clone lacks that commit, so I read the whole change. No security finding: the label is validated at the boundary (key=value, length-capped), every write goes through changeEconomics with its admin gate, and output is escaped. Tests cover the policy, label, API, form, MCP, terminal, board and meter. Non-blocking: the hint paragraph under the Economics form partly repeats the placement line (design principle 1).
Each environment is now placed by a new rule,
placeFor, insrc/core/placement_policy.ts. It is a pure function that answersownedorcloudwith a one-sentence reason:ownedand the reason says "no cloud body: bursts queue".spillAtOwnedUse. Production andalways_onapps move only when owned capacity is full.placement: cloudis honoured.placement: metalis honoured unless owned capacity is full.Where it is called. When an environment starts serving, the runtime asks the policy before it opens the meter interval. The answer becomes the interval's
placement, so cost knows which price applies. A newplacerow goes to the decision ledger throughrecordPlacementincore/decisions.ts. It holds what the policy saw, the reason and the body target. The board lists none of these rows, just as it leaves out guard rows. The decisions API still returns them.Spawn seam (
src/spawn/placement.ts). It now exports:CLOUD_BODY_CONFIGURED = false, since no cloud body exists yet (spike #339). Today every decision is thereforeowned, and production behaves as before.bodyTarget, which passes the owned-node label on to the body asnodeLabel.The runtime option
placement, which onlyspawn/child-apps.tspassed along, is replaced bycloudBodyandownedCapacity.ownedCapacitydefaults to unbounded because no owned limit is known yet.Owned-node label.
plat.sh/site=vxrailis now a stored setting (getOwnedNodeLabel/setOwnedNodeLabel) instead of a constant. Every change is kept, like the slider. A stored value that doesn't parse reads as the default. Settings → Economics has a field for it and shows "Owned metal first, on nodes labelled … ; no cloud body: bursts queue".GET /api/v1/forge/economicsnow includesplacement: { cloudBody, ownedNodeLabel, words }.PUTacceptsownedNodeLabeland returns 422bad_labelfor anything that isn't akey=valuenode label.openAwakenow returns whether it opened an interval, so a redeploy doesn't record a second decision.Not done:
getEconomicsbecause existing tests compare its result field by field.Tests: the new
test/placement-policy.test.tscovers below threshold → owned, above → cloud for a preview first, full owned + no cloud → owned with the queue reason, preferences, and label parsing.test/economics.test.tscovers the label through the API and the form and the queue line on the page.test/meter.test.tschecks that a real deploy records oneplacedecision with the target label. Gate,tsc --noEmit, design-lint and the related suites (board, contract, OpenAPI, MCP/terminal economics, spawn) pass. The forge answered 502 for several minutes before the push went through ased4e760a.To try it: Settings → Economics shows the placement line. Set a label with
PUT /api/v1/forge/economics {"ownedNodeLabel":"site=home"}. After a deploy,kind=placedecisions appear on the repository's decisions API.🤖 Generated with Claude Code
Closes #509