Placeable capacity phase 0: decisions and the live-memory baseline¶
Prompt¶
Before responding to questions or discussion points in this document, explore the shakenfist codebase thoroughly and ground your answers in what the code actually does today. Do not speculate when you could read instead. Flag uncertainty explicitly.
This is phase 0 of PLAN-placeable-capacity.md. Read that master
plan first, and the brief it was lifted from — the "Brief for the
shakenfist/shakenfist plan" in shakenfist/private-ci
docs/plans/PLAN-placeable-claims-phase-00-decisions.md. This phase
ships no production code: it produces a decisions document and a
measurement reading that the later phases cite.
Planning effort¶
High. This phase is the design: it resolves the master plan's open
questions Q1–Q8 against the code, and its measurement decides
whether the live-memory question is sequenced ahead of the shape
holds (A). The review effort is high for the same reason.
Scope¶
In:
- Resolve master-plan
Q1–Q8against currentdevelop, or record which are deferred and why, as numbered decisions this phase produces. - Design and take the sfcbr live-memory-against-ledger measurement
(placeable-claims phase 0
D9), and state its verdict: how much of the post-overcommit live-memory refusal share is a genuine shortage and how much is the double-subtraction the survey confirmed, and therefore whether the live-memory question comes beforeA. - Leave the later phases a decisions document they cite by this
plan's own
Dnumbers.
Out:
- Any production code, schema, API or client change. Those are phases 1 to 6.
- Retuning
RAM_OVERCOMMIT_RATIOor per-host reservations. If the measurement shows the host counted twice, that retune is 33fl's, made with the per-host-resource-reservations machinery; this phase records the finding and hands it over, it does not apply it. - Deciding hard claim enforcement (
CLAIM_ENFORCEMENT_HARD), which is scheduler-reservations phase 5.
What the survey found¶
Grounded against develop at 61a14af by a read-only survey on
2026-11-04. The master plan's Prompt warned that the brief's line
numbers (from ea39dff7c9) had drifted. The findings below are that
survey's; their line citations have since been repointed to
32de6cec7f, the commit this phase's Results were taken at, because
they had drifted again between the two. What each finding says is
unchanged; only where it points.
F1. The double-subtraction hypothesis is real, and the code
documents it. The scheduler's three memory sub-checks, in order
(shakenfist/scheduler.py, _has_sufficient_ram):
- Live free memory (
:760-771):available = metrics[node]['memory_available'] - reserved, refuse ifavailable - memory < 0.memory_availableispsutil.virtual_memory().available(shakenfist/daemons/resources/main.py:407-410);reservedis the node'smemory_reserved_mb(scheduler.py:279-285, read through the method at:615-617). - KSM ceiling (
:775-794):(memory_total_instance_actual + memory) / memory_max > RAM_OVERCOMMIT_RATIO. No reservation term.memory_maxispsutil.virtual_memory().total(main.py:407,409). - Committed ledger (
:808-824):used_memory_mb (+ memory, less the create's own prior placement) > limit_memory_mb.
The ledger limit is derived in _derive_cpu_memory_limits
(shakenfist/mariadb.py:26668-26671): limit_memory_mb = max(0,
floor(memory_max * ram_overcommit_ratio) - memory_reserved_mb).
Here memory_reserved_mb is subtracted from physical
memory_max. The live check subtracts the same reservation
from psutil.available, which already excludes memory the host is
using — so host/reserved memory is charged twice. The docstring at
mariadb.py:26624-26661 spells the distinction out. At a ratio of
1.0 the ledger limit is memory_max - reserved, the KSM ceiling is
memory_max (no reservation), and the live check independently
subtracts the reservation from an already-host-adjusted figure.
This is Q2, and it is confirmed in the code; the measurement's job
is to quantify it, not to discover it.
F2. The measurement needs no new code. The resources daemon
exports every raw input as a per-node Prometheus gauge: it calls
start_http_server(config.RESOURCES_METRICS_PORT)
(main.py:324, default port 13001, config.py:592-595) and sets a
Gauge per published metric (main.py:782-785). So
memory_available, memory_max, memory_reserved_mb,
memory_total_instance_actual (summed guest resident memory,
main.py:537,593) and memory_total_instance_max (summed guest
max, main.py:536,592) are all scrapeable on each hypervisor at
:13001 over a working day with no probe to write. The same dict
is persisted to the node_metrics table (main.py:787) and the
derived ledger (limit_memory_mb, used_memory_mb) lives in
scheduler_node_capacity (mariadb.py:3917-3939,
_derive_cpu_memory_limits :26618-26671). GET /admin/resources
→ summarize_cluster_resources() (scheduler.py:550-559, which
delegates to _summarize_resources() at :338, per-node payload
:457-545) gives the derived view but not a bare
memory_available or raw limit_memory_mb per node — those come
from the gauges or the DB. Decision follows (D3): the
measurement is a scrape-and-analyse task against existing gauges,
not a code change.
F3. The capability mechanism is where the master plan said.
API_CAPABILITIES (shakenfist/external_api/app.py:440-528) is a
family→tokens dict; advertised_capabilities() (:531-544)
flattens it and drops ConditionalCapability tokens whose predicate
is false. A static token is a string appended to a family list
(example today: 'cluster-capacity' under 'admin', :448); a
runtime-gated one is ConditionalCapability('token', lambda: ...)
(:502-503). Phases 2 and 3 use this for C's and A's capability
strings; nothing in phase 0 touches it.
Nothing in the survey contradicted the master plan. The one thing it
sharpened — that the baseline can be read from existing gauges — is
D3 below.
Decisions this phase makes about how it runs¶
- D1. Scope is decisions plus one measurement, no code. The
deliverable is this plan's successor decisions document and a
measurement reading, not a change to the tree. The phase is
"complete" when
Q1–Q8have answers (or recorded deferrals) and the live-memory verdict is written. - D2. The measurement is taken on sfcbr, over at least one full working day, covering a period that includes a CI-busy stretch (the refusals are load-driven), so that live room and ledger room are compared under the conditions that produced the baseline.
- D3. The measurement uses the existing per-node Prometheus gauges
on
:13001(F2), scraped into a time series, plus thenode_metrics/scheduler_node_capacityrows for the derived ledger figures. No probe code is written. 33fl owns node and scrape access; the phase asks for it rather than assuming it. - D4. The verdict is expressed as two per-node comparisons over
the window: host use (
memory_max - memory_available) against the reservationmemory_reserved_mb, and live room (memory_available - memory_reserved_mb) against ledger room (limit_memory_mb - used_memory_mb). Where live room is persistently below ledger room by about the reservation, the double-subtraction (F1) is costing a node its reservation twice; the verdict says by how much and on how many nodes. - D5.
Q1(does the live question come first?) is answered fromD4plus the baseline's live share, not in the abstract. If the double-subtraction explains most of the live-memory refusal third, the fix is 33fl's retune andAproceeds; if a genuine live shortage remains at the baseline's share, the live-memory question is sequenced ahead ofAand the master plan's order is amended. - D6.
Q3–Q7are each researched against the code before being decided (hold reference format and lifetime; the per-node held column's shape; whetherdegradednodes stop placement; the admin-scoped evacuate hold with #1364; thesf_claim/sf-clientshape verbs), with the reasoning and the rejected alternative recorded, as the brief did for its own decisions.Q8(whetherCis retired) is decided from the caller table, which already points to keeping it.
The answers to Q1–Q8 themselves are the decisions this phase
produces; they are written into the successor decisions document
as it executes, not pre-empted here.
Step plan¶
All implementation by sub-agents; the management session reviews and
commits. Each step re-surveys its own citations against current
develop (the master plan's standing instruction).
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 0a | high | opus | none | Design the sfcbr live-memory measurement per D2–D4. Specify the exact gauges to scrape from :13001 (memory_available, memory_max, memory_reserved_mb, memory_total_instance_actual, memory_total_instance_max) and the node_metrics/scheduler_node_capacity columns for the derived ledger (limit_memory_mb, used_memory_mb), the scrape cadence and window, and the two per-node comparisons of D4. Produce the analysis as a script or a documented query set that 33fl can run; it writes no shakenfist code. State what access it needs from 33fl and name the per-host-resource-reservations lever a retune would use. Output: the measurement method section of the decisions document. |
| 0b | high | opus | none | Research and decide Q3–Q7 against current develop. For each: read the relevant code (claims/admit_instance_placement, namespace_claims/scheduler_node_capacity schema, the reconciler, node states in shakenfist/node.py, sf_claim/client-python claim verbs, affinity in docs/user_guide/affinity.md), state the decision, the reasoning, and the rejected alternative. Decide Q8 from the master plan's caller table. Cross-check each decision against placeable-claims phase 0 D2/D3/D8 so this plan does not contradict its brief. Output: the decisions section. |
| 0c | high | opus | none | Assembly and Q1 verdict. Once 0a's measurement has been run by 33fl and its figures are in hand, write the live-memory verdict (D5): the two comparisons per node over the window, the share attributable to the double-subtraction vs a genuine shortage, and the answer to Q1 — proceed to A, or sequence the live-memory question first and amend the master plan's order. Then check every D against every other and against the master plan's open questions, confirm each of Q1–Q8 is answered or deferred exactly once, and amend the master plan where the verdict moved it. Output: the assembled decisions document and any master-plan amendment. Do not touch code. |
Step 0c depends on 33fl having run 0a's measurement; it is the one step that cannot complete on planning-host access alone. If the measurement cannot be scheduled promptly, 0b's decisions and 0a's method can land first and 0c's verdict follow, but the phase is not complete until the verdict is written.
Risks and mitigations¶
- The measurement needs host access this session does not have.
Mitigation:
D3makes it a scrape of existing gauges, the lowest possible ask; 0a names exactly what it needs so 33fl can run it unattended, and the step split lets the code-free decisions land without waiting on it. Who checks: the management session confirms the figures arrived before marking the phase complete. - Deciding
Q4(the held column) here pre-empts phase 3's design. Mitigation:Q4is a master-plan open question, but the phase decides the shape (a ledger dimension besideused_*, as the brief'sD3/transient-capacityOQ5already frame it), not the migration detail, which stays phase 3's. Who checks: 0b states explicitly what it leaves to phase 3. - The verdict retunes nothing, so a genuine shortage is left for
33fl. Mitigation: that is deliberate (
D1, out of scope); the phase hands 33fl a named lever (per-host reservations) and a figure, and records the hand-off so it is not lost.
Definition of done¶
- The decisions document answers or defers each of master-plan
Q1–Q8exactly once, each with reasoning and (for a decision) the rejected alternative. - The live-memory measurement has been run on sfcbr over a window
of at least one working day that includes a CI-busy stretch, and
its two per-node comparisons (
D4) are recorded with figures. - The verdict answers
Q1: eitherAproceeds, or the live-memory question is sequenced ahead of it and the master plan's Execution ordering is amended to match — checkable by reading the master plan's order prose against the verdict. - No file under
shakenfist/(code) changed:git diff --name-only develop HEAD -- shakenfist/ | grep -v '\.md$'is empty. - Every
Q3–Q7decision names what it leaves to its implementing phase, so no later phase finds the question re-opened. - The hand-off to 33fl (the named per-host-reservation lever and the measured figure) is recorded where phase 7 and 33fl will find it, not dropped.
pre-commit run --all-filespasses from the repository root.
Back brief¶
Before executing any step of this phase, back brief the operator on
your understanding of it: that it ships no code, that its measurement
is a scrape of existing per-node gauges confirmed to exist, that its
central judgement is whether the confirmed double-subtraction
explains enough of the live-memory refusal share to let A proceed,
and that any ratio or reservation retune it motivates is 33fl's to
apply, not this phase's.
Results¶
The live-memory measurement: method and figures¶
Taken 2026-11-07 against shakenfist develop at 32de6cec7f. All
code citations below were re-found at that commit; the phase plan's
F1 line numbers were taken at 61a14af and have drifted.
What changed about the method, and why it matters¶
D3 assumed this measurement needed 33fl to scrape each
hypervisor's :13001 endpoint over a future working day. It does
not. All five gauges are already in maui's Prometheus at
http://maui:9099, job="shakenfist", one series per node for
sf-1 through sf-6, with history back to 2026-08-06 (93 days).
The resources daemon publishes each stat key as a bare Gauge
with no name prefix (shakenfist/daemons/resources/main.py:784),
so the series are named exactly memory_available, memory_max,
memory_reserved_mb, memory_total_instance_actual and
memory_total_instance_max. Confirmed by reading
/api/v1/label/__name__/values.
So the measurement is retrospective: a window is chosen from
history rather than scheduled into the future. D2's requirement
that it cover a CI-busy working day becomes a selection criterion
instead of a scheduling constraint, and D3's ask of 33fl
collapses to near-nil (see the hand-off below).
What is not in Prometheus is the derived ledger. limit_memory_mb,
used_memory_mb, limit_cpu and used_cpu are all absent — the
scheduler_node_capacity row is persisted to MariaDB and never
exported. Michael's decision is that this measurement runs from
Prometheus history alone, with no cluster or database access. That
is possible for one of the two columns and not the other.
The two derived ledger columns¶
limit_memory_mb is computed exactly. _derive_cpu_memory_limits
(shakenfist/mariadb.py:26668-26671) is
and every input is a gauge. The ratio in force on sfcbr is 1.0,
set deliberately as an OOM defence in
33fl/group_vars/allsf.yml:52, with the reasoning — kolla CI guests
dirty ~16 GB of unique pages each, KSM recovers almost nothing, and
sf-6 took global OOM kills at ~1.5x allocated on 2026-08-04/05 — at
33fl/group_vars/allsf.yml:17-45. Confirmed unchanged. The
reservation was also constant across both measurement windows
(min_over_time != max_over_time returns no series), so the
derivation is stable within them.
used_memory_mb has only a proxy, and the proxy is biased in a
known direction. The ledger's figure is
SUM(instances.memory) over every placed, non-deleted instance
(_RECONCILE_USAGE_SQL, shakenfist/mariadb.py:26872-26899); the
comment above it at :26802-26813 states the distinction out loud
— these are allocation ledgers, not measurements, and a
powered-off instance or one placed but not yet defined in libvirt
still holds its reservation there. memory_total_instance_max sums
domain.info()[1] over active libvirt domains only
(shakenfist/daemons/resources/main.py:528-535, 592). Therefore:
- The proxy can only undercount:
used_memory_mb >= memory_total_instance_maxalways, because the placed set contains the active set and both sum the same nominal per-instance figure. - So proxy ledger room is an upper bound on real ledger room. Every conclusion below that says "the ledger is the tighter constraint" is therefore conservative: the real ledger is tighter still. Conclusions that depend on ledger room being large would not be safe, and none are drawn.
- The error cannot be bounded numerically from Prometheus.
instances_totalwould be the natural bound, but it is incremented inside the sameif active:branch (main.py:534), so it never differs frominstances_active— verified over 72 hours, no node where they diverge. Prometheus holds no count of placed-but-inactive instances, which is exactly the population the proxy misses. - Duplicated
INSTANCE_LOCATIONrows after a lost node (mariadb.py:26819-26828) are a second, unbounded source of the same one-directional error.
No figure below is presented as an exact ledger figure. Where a
ledger quantity appears it is labelled "proxy", and settling its
absolute value needs one SELECT against
scheduler_node_capacity — the single thing this method cannot do.
Window¶
2026-11-04T00:00Z to 2026-11-07T00:00Z, 72 hours, three
consecutive working days (Wed/Thu/Fri), sampled at 300 s. It is
representative because it holds the three highest runner-create
refusal days in the whole 93-day history — 87, 91 and 97 refusals
a day from increase(conductor_runner_create_refused_total[1d]),
against 2 to 44 a day through late October — and it sits inside the
latest segment of private-ci
docs/plans/PLAN-placeable-claims-phase-04a-regression.md:528-568,
where runner-create refusals ran 69.3 a day and in-job 89.2 a day.
Two checks against window-shopping:
- A second busy window, 2026-10-28 to 2026-10-31 (72 h, the previous refusal peak and a different segment) reproduces every conclusion, with the headline figure moving from 27.2% to 27.1%.
- The busiest single day re-run at 60 s (2026-11-06, 24 h, 7911 node-samples) gives 22.5% on the same figure and does not change any sign.
Sample completeness in the primary window is 4968 node-samples of a possible 5190 (95.7%); the gaps are deploys and daemon restarts.
D4 comparison 1: host use against the reservation¶
memory_max - memory_available against memory_reserved_mb, MB,
percentiles over the window.
| node | reserved | host use p5/p50/p95 | host use > reserved |
|---|---|---|---|
| sf-1 | 10526 | 8222 / 12888 / 19368 | 61.5% |
| sf-2 | 10526 | 9576 / 11453 / 29233 | 60.9% |
| sf-3 | 6430 | 12890 / 14440 / 33134 | 99.2% |
| sf-4 | 6420 | 8915 / 16668 / 35595 | 98.7% |
| sf-5 | 6389 | 10300 / 15687 / 31872 | 99.3% |
| sf-6 | 6389 | 15665 / 17930 / 34411 | 99.4% |
The reservations are not uniform: sf-1 and sf-2 carry 10.28 GiB
against 6.24-6.28 GiB elsewhere, consistent with the templated
max(2.0, 10% of RAM) plus the 4.0 GB infra bump on the two
database-tier nodes
(shakenfist/deploy/collection/roles/node/templates/config:29,
default at roles/node/defaults/main.yml:32, config key
documented at shakenfist/config.py:719-727).
So host use exceeds the whole reservation on 61% to 99% of
samples, and at the median on every node. psutil's available
has already removed more memory than the reservation is asking to
protect, and the live check at shakenfist/scheduler.py:760-771
subtracts it again. F1 is confirmed as arithmetic.
But the comparison as D4 words it conflates guest memory into
"host use", and that matters. process-exporter runs on all six
nodes (job="process-exporter"), so the kvm process group's RSS
gives a direct guest-resident figure and the host's own footprint
can be separated:
| node | reserved | host use less guest RSS p5/p50/p95 | exceeds reserved |
|---|---|---|---|
| sf-1 | 10526 | -2790 / 2485 / 4290 | 0.1% |
| sf-2 | 10526 | -3659 / 686 / 3899 | 0.0% |
| sf-3 | 6430 | -5174 / 1430 / 2461 | 1.3% |
| sf-4 | 6420 | -5629 / 818 / 3395 | 1.3% |
| sf-5 | 6389 | -5223 / 3291 / 4489 | 0.6% |
| sf-6 | 6389 | -4586 / 1570 / 3908 | 0.0% |
(Negative values are page cache counted as unavailable net of guest RSS; they are an artefact of mixing two exporters' notions of "used" and should be read as "well below the reservation".)
The host's non-guest footprint is 0.7 to 3.3 GiB at the median
against a 6.2 to 10.3 GiB reservation, and exceeds the
reservation on under 1.5% of samples everywhere. So the double
subtraction is not duplicating a cost the host is actually
incurring; it is adding a second buffer of the reservation's full
size on top of a genuinely-measured host use that is a third to a
quarter of it. That second buffer is not obviously unprincipled —
33fl/group_vars/allsf.yml:22-25 and
shakenfist/scheduler.py:796-808 both note that the measurement
lags placement, and a buffer against that lag is what the
reservation is doing in the live check. Whether it is the right
buffer is a separate question from whether it is a duplicate.
D4 comparison 2: live room against ledger room¶
Live room memory_available - memory_reserved_mb against proxy
ledger room limit_memory_mb - memory_total_instance_max, MB. The
gap column is ledger room minus live room, so a positive gap
is the plan's predicted signature (live room below ledger room).
| node | live room p5/p50/p95 | proxy ledger room p5/p50/p95 | gap p5/p50/p95 | gap > 0 |
|---|---|---|---|---|
| sf-1 | 34364 / 40844 / 45510 | 29156 / 37348 / 45540 | -11176 / -3533 / 582 | 32.8% |
| sf-2 | 24499 / 42279 / 44156 | 8676 / 37348 / 45540 | -23034 / -6720 / 3013 | 33.3% |
| sf-3 | 24694 / 43388 / 44938 | 4580 / 41444 / 41444 | -27941 / -3571 / -2258 | 0.1% |
| sf-4 | 22220 / 41163 / 48900 | 471 / 33239 / 49623 | -28835 / -3875 / 2349 | 35.7% |
| sf-5 | 25612 / 41794 / 47184 | 4236 / 41100 / 49292 | -28806 / -1734 / 2210 | 16.5% |
| sf-6 | 23074 / 39555 / 41820 | 141 / 37005 / 37005 | -27438 / -3750 / -2496 | 0.6% |
Full gap distribution (MB):
| node | p0 | p5 | p25 | p50 | p75 | p95 | p100 |
|---|---|---|---|---|---|---|---|
| sf-1 | -18245 | -11176 | -6945 | -3533 | 64 | 582 | 2812 |
| sf-2 | -36480 | -23034 | -11564 | -6720 | 2167 | 3013 | 3307 |
| sf-3 | -40155 | -27941 | -13127 | -3571 | -2488 | -2258 | 2010 |
| sf-4 | -43818 | -28835 | -10777 | -3875 | 1585 | 2349 | 2577 |
| sf-5 | -39788 | -28806 | -12696 | -1734 | -766 | 2210 | 3697 |
| sf-6 | -38783 | -27438 | -15040 | -3750 | -3017 | -2496 | 3216 |
The figures point the other way, and this needs stating plainly¶
The predicted signature is absent. Live room is not persistently below ledger room by about the reservation; it is persistently above ledger room. The median gap is negative on all six nodes, by 1.7 to 6.7 GB, and the p5 gap runs -11 to -29 GB (-44 GB at p0). The gap is positive on 0.1% to 36% of samples depending on the node, and never by anything like the reservation. The second busy window is the same, more strongly (medians -2.1 to -12.7 GB). This is not a measurement artefact of the proxy: the proxy can only overstate ledger room, so correcting it would push the gap further negative.
The arithmetic says why. At ram_overcommit_ratio = 1.0,
— the two reservations cancel. The gap is therefore not a function
of the reservation at all; it is the difference between what the
host has actually faulted in and what the ledger has committed.
And on sfcbr the second term dominates the first, because guests
are charged at nominal allocation and have not faulted anything
like it. memory_total_instance_max - memory_total_instance_actual
is exactly 0 at every percentile on every node: nothing
balloons, so the nominal and "actual" gauges are the same number,
as 33fl/group_vars/allsf.yml:39-42 warned.
That identity relocates the reservation's real cost. With
actual == nominal, proxy ledger room is the KSM headroom minus
the reservation, exactly:
| node | KSM headroom p5/p50/p95 | proxy ledger room p5/p50/p95 | KSM less ledger, p50 | reserved |
|---|---|---|---|---|
| sf-1 | 39682 / 47874 / 56066 | 29156 / 37348 / 45540 | 10526 | 10526 |
| sf-2 | 19202 / 47874 / 56066 | 8676 / 37348 / 45540 | 10526 | 10526 |
| sf-3 | 11010 / 47874 / 47874 | 4580 / 41444 / 41444 | 6430 | 6430 |
| sf-4 | 6891 / 39659 / 56043 | 471 / 33239 / 49623 | 6420 | 6420 |
| sf-5 | 10625 / 47489 / 55681 | 4236 / 41100 / 49292 | 6389 | 6389 |
| sf-6 | 6530 / 43394 / 43394 | 141 / 37005 / 37005 | 6389 | 6389 |
The reservation is subtracted twice on sfcbr, as F1 and Q2
suspected — but the two places are the ledger and the KSM
ceiling, not the ledger and the live check. The live check is the
loosest of the three sub-checks almost always.
Which sub-check actually refuses¶
Per node-sample, applying each of _has_sufficient_ram's three
sub-checks (shakenfist/scheduler.py:753-824) to a 16384 MB shape
— the size of 12/16384/160, the shape refused most in the latest
04a segment:
| node | samples | live refuses | KSM refuses | ledger refuses | live is sole refuser |
|---|---|---|---|---|---|
| sf-1 | 829 | 0 | 0 | 0 | 0 |
| sf-2 | 829 | 18 | 23 | 133 | 0 |
| sf-3 | 829 | 6 | 139 | 163 | 0 |
| sf-4 | 828 | 22 | 97 | 123 | 0 |
| sf-5 | 826 | 9 | 138 | 167 | 1 |
| sf-6 | 827 | 10 | 157 | 203 | 0 |
| all | 4968 | 65 | 554 | 789 | 1 |
The committed ledger is the tightest of the three on 64% to 100% of samples per node, and it refuses in 789 of the 790 samples where any check refuses. The live check is the sole reason a node is dropped in 1 node-sample in 4968 (0.02%), and that one sample would have cleared if the reservation were subtracted once rather than twice. At 60 s resolution on the busiest day the figure is 2 in 7911.
Counterfactually removing each reservation term:
| node | samples | refusals solely from the ledger's reservation term | % of that node's refusals |
|---|---|---|---|
| sf-1 | 829 | 0 | 0.0% |
| sf-2 | 829 | 93 | 69.9% |
| sf-3 | 829 | 24 | 14.7% |
| sf-4 | 828 | 23 | 18.7% |
| sf-5 | 826 | 29 | 17.3% |
| sf-6 | 827 | 46 | 22.7% |
| all | 4968 | 215 | 27.2% |
(A "reservation-only" refusal is one the ledger refuses while both
the KSM ceiling and the live check admit. Dropping the reservation
from limit_memory_mb makes that check identical to the KSM
ceiling, so the counterfactual is exact given the proxy.)
So the double-subtraction costs roughly 27% of per-node memory
refusals — but through limit_memory_mb, not through the live
check. 215 node-sample refusals against 1. The second window
gives 27.1% and 0; the 60 s day gives 22.5% and 2. The reservation
withholds 46680 MB (45.6 GiB) of limit_memory_mb across the six
nodes, and that is the number a retune moves.
How much of the live-memory margin the double-subtraction is¶
| node | live check refuses | would refuse with a single subtraction | reservation as % of median live room |
|---|---|---|---|
| sf-1 | 0.0% | 0.0% | 25.8% |
| sf-2 | 2.2% | 0.0% | 24.9% |
| sf-3 | 0.7% | 0.0% | 14.8% |
| sf-4 | 2.7% | 0.8% | 15.6% |
| sf-5 | 1.1% | 0.0% | 15.3% |
| sf-6 | 1.2% | 0.5% | 16.2% |
The reservation is 15% to 26% of the live margin a node reports, and removing it would take live-check refusals of a 16 GiB shape from 0.0-2.7% of samples to 0.0-0.8%. That is the whole of what the live-side double-subtraction is worth: a few percent of a check that is almost never binding.
One node behaves differently, and one cluster-level fact¶
sf-1 never refuses a 16 GiB shape on any of the three checks, in either window. It carries the largest reservation (10.28 GiB, infra bump) and the smallest guest allocation: its proxy ledger room p5 is 29156 MB where sf-6's is 141 MB. It is the quietest hypervisor in the fleet by committed memory, so no check binds. sf-2 shares the same reservation and is the most affected by it (69.9% of its refusals are reservation-only), which is the expected consequence of a 10.28 GiB subtraction on a node that does carry load. The other difference is sf-3 and sf-6, whose proxy ledger room has p50 equal to p95 — long plateaux at a fixed guest allocation, consistent with static runners rather than transient CI guests — and whose gap is negative at every percentile up to p95.
At no sample in either window did every node refuse the shape.
826 of 826 complete cluster samples in the primary window, and 865
of 865 in the second, had at least one node that would admit a
single 16 GiB guest. A single-shape create would have been placed
at every moment sampled. The refusals 04a counted must therefore
come from sets of shapes, from the sub-sampling-interval
transients the gauges smooth over, from the proxy's undercount, or
from any combination of the three. The third of those deserves the
most weight and bounds what this paragraph can claim: real
refusals certainly occurred in this window, so where the proxy
says a node always had room, the proxy is loose rather than the
refusals imaginary. This line is therefore not evidence about
fragmentation on its own. It is consistent with 04a's
fragmentation finding (shakenfist/private-ci
PLAN-placeable-claims-phase-04a-regression.md, F8 and D5:
374 of 461 in-job refusals in the latest segment are packing
failures) and with the constraint that plan referred inward — a
hold has to defend a slot for a shape, and aggregate headroom is
not the binding quantity — but the independent evidence for that
is 04a's, not this measurement's.
Method, re-runnable¶
The script is
scratchpad/step-0a/measure-live-vs-ledger.py, standard library
only, read-only, no cluster or database access:
python3 -I measure-live-vs-ledger.py \
--prometheus http://maui:9099 \
--start 1793750400 --end 1794009600 --step 300 --ratio 1.0
--start/--end are unix seconds; --ratio must be set to the
RAM_OVERCOMMIT_RATIO in force. It issues six /api/v1/query_range
calls (the five gauges plus the kvm process group's RSS), joins
them on the step timestamps, drops any timestamp missing a gauge,
and prints the two D4 comparisons, the gap distribution, the
sub-check attribution, the counterfactuals and the cluster view.
Outputs for the three runs above are beside it as
run-2026-11-04-to-11-07.txt, run-2026-10-28-to-10-31.txt and
run-2026-11-06-step60.txt.
To settle what the proxy cannot, one query is needed:
That is not required for any conclusion above, because the proxy's bias runs the conservative way for all of them.
What 33fl would need to provide, and the retune lever¶
For the measurement: effectively nothing. D3 asked 33fl for
node and scrape access. Neither is needed. Everything above was
taken from http://maui:9099 over plain HTTP from a planning host,
and the only 33fl artefacts read were the repository's own
group_vars and host_vars files. Recording this as a result:
phase 0's one step that D1 expected could not complete on
planning-host access alone can in fact complete on it, and D3's
ask is withdrawn. The one residual ask is optional — a read of
scheduler_node_capacity to replace the used_memory_mb proxy
with the ledger's own figure.
For a retune, the lever is NODE_RAM_RESERVATION_GB — a
per-node float in GB (shakenfist/config.py:719-727), rendered as
SHAKENFIST_NODE_RAM_RESERVATION_GB from the node role's
node_ram_reservation_gb variable
(shakenfist/deploy/collection/roles/node/templates/config:29,
default 2.0 at roles/node/defaults/main.yml:32, declared in
roles/node/meta/argument_specs.yml:118). It is overridable
per-host from the 33fl inventory, exactly as
33fl/host_vars/sf-1.yml:69 already overrides its CPU sibling.
The measured values in force are 10526 MB on sf-1 and sf-2 and
6389-6430 MB on sf-3 to sf-6.
Two things about that lever, from the figures:
- It is the ledger's cost, not the live check's. Lowering it
raises
limit_memory_mbone-for-one and relaxes the check that is actually binding (27% of refusals), viamariadb.py:26668-26671. Lowering it also relaxes the live check, but the live check is worth 1 node-sample in 4968. - The alternative retune, raising
RAM_OVERCOMMIT_RATIOabove 1.0, is not recommended by this reading and is out of this phase's scope in any case: it is a deliberate OOM defence (33fl/group_vars/allsf.yml:17-45) and 04a explicitly declined to touch it. Removing the reservation fromlimit_memory_mbwhile leaving the ratio alone would make the ledger exactly as permissive as the KSM ceiling the same ratio sets, which is a far smaller change than moving the ratio.
The hand-off D1 and the phase's definition-of-done item 6 ask
for, stated once: the lever is per-host
node_ram_reservation_gb in the 33fl inventory, the measured
figure it withholds is 46680 MB (45.6 GiB) of limit_memory_mb
across the six sfcbr nodes, and that accounts for 27.2% of
per-node memory refusals of a 16 GiB shape over
2026-11-04 to 2026-11-07.
What this section deliberately does not say¶
It does not answer Q1. The verdict — whether the live-memory
question is sequenced ahead of A, and how the "live third" of the
baseline is now divided between margin and shortage — is step 0c's,
and needs these figures plus 04a's refusal-reason counts and C's
shadow reading. What is recorded here is factual: the
double-subtraction Q2 asks about is real and costs about 27% of
memory refusals, but it costs them in the committed ledger rather
than in the live check, and the live check — the one Q1 is about
— was the sole reason a node was dropped in 1 node-sample in 4968.
Decisions this phase produces: Q3 to Q8¶
Researched against develop at 32de6cec7f on 2026-11-07. Every
citation below was re-found in that tree rather than carried from
the master plan or the brief, because the line numbers in both had
drifted — _has_sufficient_ram() is now
shakenfist/scheduler.py:753, not :520, and
_claim_capacity_guard() is shakenfist/mariadb.py:29927, not
:29563. Four citations had drifted in substance as well as in
line number — two in the master plan's Q7, two in the brief — and
each is named where it bears.
D7 to D12 answer Q3, Q4, Q5, Q7 and Q8 and defer
Q6. Each states what it leaves to its implementing phase, so no
later phase finds the question re-opened.
D7 (Q3): the hold reference and its lifetime¶
Decision. A hold is a row with its own uuid4, and the reference
on POST /instances is one new optional body parameter carrying
that uuid and nothing else. A hold is created with its claim and
ends only when the claim is deleted, when the reconciler's expiry
sweep lapses it, or when an operator shrinks the claim below the
sum of its holds — not when its instance is deleted, not on a
preflight abort, and not on a refused create. A create larger than
every hold on every dimension is an unheld create. An explicit
reference that cannot be honoured is a 409 carrying a reason
token; a reference to another namespace's hold is a 404.
The reference is a uuid, resolved namespace-scoped. A claim
already has no name and is addressed by uuid alone, and
arg_is_claim_ref() (shakenfist/external_api/auth.py:1504-1541)
spells out why the namespace segment is checked against the
object's own: "a uuid on its own would let a caller address a claim
through any namespace's URL, which would make the namespace segment
decorative". A hold has the same property, so it takes the same
treatment — a uuid that is not a hold of the request's namespace
answers the same 404 a missing hold does, disclosing nothing
about what other namespaces hold. A shape expression
(cpus=4,memory=8192) is rejected as a reference because a shape
is not a key: two holds of one claim can share a shape, and G3's
best fit exists precisely because a create and a hold need not be
the same size.
The parameter is a body key with a handler guard, like every other
one. POST /instances takes flat body parameters
(shakenfist/external_api/instance.py:718-721) and the handler is
"a chain of handler guards, one block per body parameter"
(:745-757), which is also where
docs/developer_guide/writing_an_endpoint.md says a guard for a
new body key belongs. The hold reference is one more block, and it
answers before Instance.new() so a refused reference creates
nothing.
Lifetime: the claim's coverage, not the instance's. A hold ends
when its claim is deleted (NamespaceClaim.hard_delete(),
shakenfist/namespace_claim.py:387-397, which "gives the capacity
back inside the same transaction that removes the row"), when the
reconciler's expiry sweep moves it to coverage expired
(shakenfist/mariadb.py:27223-27228), or under the shrink rule
below. It does not end when its instance is deleted: the
release path returns the hold to unconsumed, booked on the node the
instance was last on, which is the brief's D3 and the reason
three G2 callers can delete and recreate under one claim.
An expired claim's holds are released, not displaced.
Displacement (brief D8) keeps a hold inside its claim's aggregate
limits. An expired claim has no aggregate limits to sit inside —
the reconciler sums claimed_* from active claims only, which
_direct_update_namespace_claim() states in terms
(shakenfist/mariadb.py:30384-30388) — so there is nothing for a
displaced hold to be inside, and the honest outcome is release.
A re-dated claim keeps its holds; a replaced one does not.
sf_claim's state=present path re-dates the existing claim
rather than replacing it
(shakenfist/deploy/collection/plugins/modules/sf_claim.py:43-47),
so the 30-day static-runners and static-ci claims keep their
bookings across every run of the play. Its lapsed path creates a
replacement and then deletes the lapsed rows (:59-67), and holds
belong to a claim uuid, so the replacement starts with none. The
holder re-books, exactly as it does for a displaced hold under the
brief's D8.
A shrink below used + held releases holds rather than being
refused. The shrink floor today is used_* and no further, tested
as a SQL guard so a concurrent create cannot slip under it
(shakenfist/mariadb.py:30368-30374, diagnosed by
_blocked_by_shrink_floor() at :30534-30556). That floor stays
where it is, and a shrink that leaves sum(holds) above the new
limit releases holds, most recently booked first, until the claim
fits — each release an event on the claim and named in the update's
reply. This is what the brief's D3 means by a hold ending because
its claim was "shrunk below it".
Consumption edges.
- A create larger than every hold is an unheld create in a
claimed namespace. It charges the claim row as today and is
placed against node room net of every hold on the node, its own
namespace's included (
G6). No hold is split: labelling part of a node's room held when it is not, to serve a case whose remedy is a larger hold, is the brief'sD3rejection and it stands. - Two creates cannot take one hold. The hold is taken by a
guarded
UPDATEinside the admission transaction, which is the same mechanism every other counter there uses (_direct_admit_instance_placement(),shakenfist/mariadb.py:28750-29038); the loser of the race falls through to the next matching hold, or becomes an unheld create. - A displaced hold cannot be consumed. It is booked on no node,
so it is not a match for implicit consumption, and an explicit
reference to one is refused with the
409below. - A hold carries no affinity. Hard affinity matches the tags of
instances already placed on a candidate hypervisor, in the
creator's own namespace (
shakenfist/scheduler.py:623-665;docs/user_guide/affinity.md:134-139); it is re-evaluated on every schedule, restarts included (affinity.md:148-161); and the first member of arequire_with_taggroup cannot be created under the constraint that defines it (affinity.md:194-208). A booking made before any instance exists therefore has nothing to evaluate. Affinity is tested at consumption instead: a hold whose node does not satisfy the create'srequire_*constraints is not a match, which is the brief'sD3, and the hold itself never carries one, which this decision adds. - An explicit reference that cannot be honoured is a
409with a reason token — consumed, expired, displaced, too small, or affinity.409is the status this API already gives when "a request conflicts with the durable state of a resource" (shakenfist/external_api/auth.py:1378-1382), and the refusal is not a507and carries noRetry-Afterbecause no wait makes that hold usable. The brief'sD3asked for a non-capacity, non-transient error naming why; this names the status and says the reason is a token rather than prose.
Rejected: release the hold when its instance is deleted. It turns
every delete-and-recreate into a race the holder can lose, which is
the thing a hold exists to prevent, and three G2 callers do
exactly that. The brief's D3 rejected it and nothing in the code
has changed to reopen it.
Rejected: raise the shrink floor to used + held and answer
below_usage. It would make returning capacity harder than taking
it — an operator would have to find and release each hold before
the claim could shrink — and the API already treats a shrink as the
operator's explicit instruction. The claim-update docstring's "there
is no auto-grow: every change here is one an operator asked for"
(shakenfist/mariadb.py:30355-30357, where the D15 it cites is
scheduler-reservations', not this plan's) is a statement about
growth, not about refusing to give room back.
Rejected: a name or a shape expression as the reference. A hold has no name to give it, and a shape is not unique within a claim.
Left to the implementing phases. Phase 4 owns the best-fit
comparator's tie-breaks beyond memory, then vCPUs, then disk; the
wire names of the hold fields on the create's reply and on the
refusal; whether the reason token rides in its own key or in
error; and where a consumed hold's uuid is surfaced on the
instance. Phase 3 owns where the hold-taking statement sits in the
transaction's canonical table order. Phase 6 owns the client and
sf_claim spelling of the reference (D11).
D8 (Q4): the per-node held column's shape¶
Decision. Holds are stored as one row per hold in a new table —
uuid, claim uuid, namespace, the three dimensions, the booked node
(nullable, where NULL means displaced), and the consuming
instance uuid when it has one. That table is the ground truth. The
per-node held dimension is three columns on
scheduler_node_capacity — held_cpus, held_memory_mb,
held_disk_gb, BIGINT NOT NULL DEFAULT 0, beside used_* — and
nothing else: no held column on namespace_claims and none on
cluster_capacity. The reconciler recomputes the three counters
from the hold rows in the same pass and from the same read as
used_*. Claim migration carries no bookings.
Why rows, and why also columns. The node guard is one WHERE
clause inside one UPDATE (_claim_node(),
shakenfist/mariadb.py:29013-29037), so used + held + request <=
limit needs held as a column on the row the guard already locks.
But a column cannot be recomputed from itself: the reconciler is
"the authority on every counter it writes, because it recomputes
each one from ground truth" rather than adjusting it incrementally
(shakenfist/mariadb.py:27196-27205), and for used_* that ground
truth is the instance_location reference rows that
_RECONCILE_USAGE_SQL (:26872) folds up. Holds need the same
split: rows to recompute from, counters to guard against. The
brief's D3 asked for exactly this when it said the reconciler
"must recompute held_* from hold rows too, or a pass will
overwrite the bookings".
The counters are also what the live checks narrow against. The
brief's D2 requires that every other create's live memory check
count a node's unconsumed holds as if resident.
_has_sufficient_ram() already reads the node's capacity row for
its third sub-check (shakenfist/scheduler.py:808-826), so a
held_memory_mb column on that row is readable from the live check
without a second query. Holds stored only as rows would make the
live check join on the hot path.
What a consumed hold contributes. A consumption moves the
create's request from held_* to used_* and leaves the hold row
at its full size, so the difference stays booked while the instance
runs (brief D3). used + held therefore never double-counts a
consumed hold, and the reconciler's recompute is SUM over the
node's hold rows of the hold's size less the consuming instance's
request, which is zero for an unconsumed hold. That is why the hold
row records the consuming instance and not merely a boolean: the
residue cannot be derived without it. The consumed hold's node
follows its instance on a move, so a later delete returns the hold
to the node the instance was actually on.
No namespace-side and no cluster-side held counter. A hold sits
inside its claim's limit_*, and a claim's limits are already
inside cluster_capacity.claimed_*, so a second held counter on
either row would be a ledger that agrees with the first only until
the next write — the reason _summarize_resources() gives for
publishing the singleton rather than recomputing it from the claims
(shakenfist/scheduler.py:358-373). The claim-side invariant
sum(holds) <= limit_* is tested as a SQL aggregate over the hold
rows inside the claim's own transaction. Claim create and update
are not a hot path, unlike instance admission, so an aggregate
there costs nothing the design has to recover.
A correction to the brief. The brief's D3 says the claim's
limits are validated against the sum of its holds "through the same
aggregate guard (_claim_capacity_guard())". That guard tests
whether the cluster can keep the claim — claimed + requested +
GREATEST(0, unclaimed_used - migrated) <= total
(shakenfist/mariadb.py:29927-29972) — and must not gain a hold
term; its docstring warns twice against adding to the migrated
term. The hold-sum invariant is a different test, in the same
transaction. The brief's intent (validated in SQL, inside the claim
transaction, at creation and update) is honoured; its naming of the
guard is not.
Reconciler recomputation. held_* for a node is the SUM above
over hold rows whose node is that node and whose claim is in
coverage active, computed in the pass that already recomputes
usage (_reconcile_fetch_usage(), :27085-27108, called at
:27327) and written with the node's limits. The pass's removals
list (:27360-27368) is the trigger for re-place-or-displace; see
D9.
One trap worth naming here so phase 3 does not fall into it.
held_disk_gb must not feed _derive_disk_limit_gb()
(:26676-26712). That limit is used_disk_gb plus the headroom
the filesystem reports, so held disk is deliberately outside it;
adding held to the used term would raise the limit by the holds
and the guard would then admit them a second time.
Claim migration carries no bookings. The migrated-drawdown term
moves a namespace's existing instance usage off
unclaimed_used_* and onto the claim at claim creation, and is
pinned at zero for a grow (:29927-29957). A namespace with no
claim can hold nothing, so there are no hold rows at the only
moment that term is non-zero. The other reading of "migration" — a
placement move — is answered above: a move updates the hold row's
node and touches no held_* arithmetic, because a consumed hold's
room is in used_*, which the existing floored decrement and
guarded increment already move (_floored_node_decrement(),
:28284-28310).
Visibility (G8). _summarize_resources() publishes cpu_held,
memory_held_mb and disk_held_gb per node beside the committed
figures it already publishes
(shakenfist/scheduler.py:457-545), and nets held room out of
cpu_available, ram_available and disk_available, because
those are the figures a client-side ledger model reads. The fields
go in that shared payload builder, not in the bound
Scheduler.summarize_resources() at :1377, whose own docstring
records it as retained for the test suite and called by no
production code.
Rejected: holds as a JSON document on the claim.
namespace_claims doubles as the claim's static-values table and
has no attributes table (shakenfist/mariadb.py:3955-3983; there
is no namespace_claim_attributes anywhere), the guard cannot sum
a JSON blob per node in SQL, and the reconciler could not recompute
held_* without parsing it in Python.
Rejected: holds as object_references rows. A reference row
carries relationship_value and nothing else
(shakenfist/schema/object_reference.py:7-9,24-28), so three
dimensions, a consumption state and a consuming instance would
have to be encoded into one string.
Rejected: a per-namespace held column on the node row. It would
let the guard answer "held by whom" in SQL, at the cost of a row
per (node, namespace) pair and a second recompute fold. The guard
never needs the holder: a hold binds every create including the
holder's own unheld ones (G6), and the consuming create excludes
its own hold by uuid, not by namespace.
Left to phase 3. The table's name and exact DDL, its index set,
its entry in EXPECTED_SCHEMA_VERSIONS
(shakenfist/mariadb.py:575-622) and its place in
ensure_schema()'s canonical list; the
SCHEDULER_NODE_CAPACITY_VERSION bump from 1 to 2
(:546) and the existence-guarded ADD COLUMN migration, for
which _add_missing_namespace_claims_columns() (:4117-4144) and
_ensure_namespace_claims_schema() (:4146-4179) are the
precedent rather than a specification; the proto fields; and
whether the consumption state is its own column or is read from the
nullable instance uuid alone. This decision settles the shape —
rows plus three node counters, nowhere else — and deliberately not
the migration.
D9 (Q5): whether degraded nodes stop placement¶
Decision. A degraded node keeps accepting placements and keeps
accepting holds. The nodes that stop accepting holds are the
complement of NODE_ACTIVE_STATES — error, missing,
stopping, stopped and deleted — plus a node that is not a
hypervisor and a node whose metrics have aged out. The operative
trigger is not a state list: it is the loss of the node's
scheduler_node_capacity row, which the reconciler already
computes from exactly those conditions. A node that loses its row
re-places or displaces its unconsumed holds in that pass.
degraded is active, and the code says so twice.
NODE_ACTIVE_STATES is {'initial', 'created', 'degraded'}
(shakenfist/constants.py:47), under a comment that reads
"degraded is active: a node with one unhealthy store still runs
instances" (:38-40), and
docs/developer_guide/state_machine.md:400-401 says "a degraded
node is still a scheduling candidate". The state means only that
some registered daemon reports stopping or stopped
(Node.get_degraded_daemons(), shakenfist/node.py:705-715), and
the registered set includes daemons an instance does not need —
eventlog, checksums, cleaner, transfers
(shakenfist/node.py:246-249).
The state that means the hypervisor has actually failed is
error, and it is not active. Node.INACTIVE_STATES is
{deleted, error, missing} (shakenfist/node.py:242), a
resource-health error clears only through sf-ctl clear-node-error
(docs/developer_guide/state_machine.md:402-409), and
Node.set_daemon_state() carries a no_reconcile list whose whole
purpose is to stop an errored node being downgraded to degraded —
"which is a schedulable ACTIVE_STATE" — the moment one of its
daemons reports stopped (shakenfist/node.py:637-650). That
comment names the sf-6 failure: without it the node "would flap
error<->degraded and keep receiving instances". Holds must follow
placement here, because a hold that refused a node placement
accepts would be booked nowhere while creates landed freely, which
inverts what a hold is for.
The trigger is the capacity row, not a state test. The
reconciler removes a node's capacity row when the node is not in
NODE_ACTIVE_STATES, is a known non-hypervisor, has no metrics row
fresher than RECONCILE_METRICS_MAX_AGE_SECONDS, or is not in the
nodes table at all (shakenfist/mariadb.py:27295-27368, with the
active-state query at :27313-27321 and removals at
:27360-27364). That one list already covers every case the
brief's D8 enumerated and two it did not, and it is recomputed on
the same pass that D8 asks to recompute held_* on — so
re-place-or-displace needs no new condition of its own. The
freshness window is 900 seconds (:716, "fifteen minutes absorbs
any skew a working NTP setup produces"), so a momentary metrics gap
does not displace a holder's bookings.
A correction to the brief. The brief's D8 lists the node states
as missing, stopping, stopped and degraded
(shakenfist/node.py:233-236 — still those lines) and leaves
degraded to this plan. The list is incomplete in the direction
that matters: it omits error, which is the state a failed
hypervisor actually reaches, is the only one of the set that sticks
until an operator clears it, and is the one the sf-6 outage
produced. D9 adds error and deleted and takes degraded out.
The brief asked the question rather than answering it, so nothing in
it needs to yield.
Rejected: a new no-new-holds node state. A lame-duck or
maintenance state is #1364's (D10), and minting one here would
ship a state with no operator verb to set it and no evacuate
operation to use it.
Rejected: holds refuse degraded, placement does not. Two
admission rules for one node, differing on a signal — a stopped
eventlog daemon — that has nothing to do with capacity.
Rejected: treat the conductor's maintenance drain as a node state.
It is not one: it stops the conductor creating namespaces, so it
books no holds, which is the brief's D8 and unchanged by this.
Left to phase 4. The re-place-or-displace transaction itself, the
most-recently-booked-first ordering, the event names on the claim,
the metric, and the displaced-hold fields in the claim's view and
in GET /admin/resources. Phase 3 owes it only the held_*
recompute in the pass whose removals list it reuses.
D10 (Q6): the admin-scoped hold for evacuate¶
**Decision. Deferred. #1364 carries no design to design against, and there is no lame-duck state, no drain state and no evacuate operation in the tree to attach an admin hold to. The owner is the
1364 implementation, which the master plan already carries in¶
Future work rather than as a phase. Phase 3 owes it one thing: the hold row's claim uuid must be nullable, so an admin hold can later be booked against an aggregate that is not a namespace claim without a migration.**
What #1364 actually says. Read with gh issue view 1364 on
2026-11-07: node delete exists, and the residual is "no
lame-duck/maintenance/drain state and no evacuate operation",
rescoped in 2026-06, with one comment pointing at a partial fix in
two old pull requests. There is no proposed state, no proposed
operation and no destination-selection rule.
And the tree agrees. A search of shakenfist/ for lame-duck,
evacuate and drain finds only the log and eventlog drainer threads;
the sole mention of evacuation in the documentation is
docs/developer_guide/state_machine.md:394, where a node is
"manually evacuated and removed" by an operator before being
deleted. PLAN-scheduler-reservations.md:1544-1549 already reached
the same conclusion about the same question — the node-scoped case
that justifies this is an operator's, and "design it then, against
the claims as they stand after phase 5, not before".
What would settle it. #1364 shipping a node lifecycle state that
means "accept no new placements" and an evacuate operation that
names its destinations. Until both exist, an admin hold has no
caller and no aggregate limit to be drawn from: a claim is
per-namespace (shakenfist/mariadb.py:3993-4024), and an evacuate
books room for instances in namespaces it does not own.
What this plan must not foreclose. held_* is per node and
dimension-only (D8): it does not record who holds the room, so a
cluster-scoped holder needs no column there. The only thing that
ties a hold to a namespace is the hold row's claim uuid, so phase 3
must not make that column the table's primary key or its only
non-null parent. A nullable claim uuid leaves #1364 free to book
against the cluster singleton (cluster_capacity,
shakenfist/mariadb.py:4056-4086), which is where an admin's
aggregate would have to live. That is a constraint on phase 3's
DDL, not a design for the admin hold.
Rejected: deciding it now anyway — an admin: true flag on a
hold, or admin holds in the system namespace's claim. Both fix
where an evacuate's aggregate room comes from before anything can
say how an evacuate chooses destinations, and the master plan's
"designed with #1364 rather than guessed at here" exists to stop
precisely that.
D11 (Q7): expressing shapes, and the stale claim verbs¶
Decision. sf-client and sf_claim express a shape as a
comma-separated key=value list — cpus, memory in MB, disk
in GB, count — repeated once per shape, which is the idiom both
already use for a long-form networkspec, diskspec and videospec.
The hold reference is a single --hold <uuid> option on instance
create and a hold: key on sf_instance. The operator guide's
claim-verbs statement has already been corrected on develop; the
stale statements phase 6 must fix are in three other files.
The expression, in the repository's own idiom. sf-client
already takes a structured multi-valued argument as a repeatable
option whose value is a comma-separated key=value list:
--networkspec and --diskspec
(client-python/shakenfist_client/commandline/instance.py:358-364,
parsed by _parse_detailed_netspec() at :317-331), documented
for the collection as size=20,base=debian:11,bus=sata
(docs/user_guide/ansible.md:267) and for video as
model=qxl,memory=65536 (:275). So:
sf-client namespace claim create ... --shape 'cpus=4,memory=8192,disk=200,count=3', repeatable, and the same option onclaim update. The units are those the claim's own fields already use —limit_memory_mbandlimit_disk_gb(client-python/shakenfist_client/commandline/namespace.py:353-386) — so a shape and a limit are denominated the same way in one command.sf_claimgainsshapes:, a list of mappings with those four keys, each of which may also be written as thekey=valuestring the collection already accepts elsewhere. Shapes join the set the module reconciles: a run whose shape multiset differs from the server's is a change, exactly as a differing limit is (LIMIT_FIELDS,shakenfist/deploy/collection/plugins/modules/sf_claim.py:265).--hold <uuid>onsf-client instance create, andhold:onsf_instance. Both are sent only against a server that advertises the capability, as_require_claim_verbs()already establishes the pattern for (sf_claim.py:275-302).
The stale statement is not where the plan says it is. The master
plan's Q7 says the claim verbs are unreleased since client-python
v0.8.3 and that docs/operator_guide/scheduler.md still says
sf-client has no claim verbs. Both halves have drifted:
- client-python
v0.8.4was tagged on 2026-10-28 and published on 2026-10-31, and it carries all five verbs (client-python/shakenfist_client/apiclient.py:1645-1700;client-python/shakenfist_client/commandline/namespace.py:251-450). The verbs are released. docs/operator_guide/scheduler.md:445-449has already been corrected: it says claims are administered withsf-client namespace claimand itscreate,show,list,updateanddeleteverbs, "which needshakenfist-client0.8.4 or later".
What is still stale, and is therefore what phase 6 fixes, is three other places that still say no release carries the verbs and tell the operator to install the client from git:
docs/user_guide/ansible.md:24-30shakenfist/deploy/collection/requirements.txt:16-20shakenfist/deploy/collection/plugins/modules/sf_claim.py:27-32(therequirements:block) and:275-302(_require_claim_verbs()'s failure message)
_require_claim_verbs() itself stays. It is a feature probe
against the client that was actually built, and its docstring
already says it "starts passing on its own once a release does
carry the verbs"; only its prose changes, to name 0.8.4 rather than
a git URL.
Rejected: a positional triple (--shape 4:8192:200). Unreadable
at the point of use, and it cannot gain a fifth key without
breaking every caller. The key=value spec idiom already exists
here and already has a parser.
Rejected: shapes as a JSON string argument. sf-client takes
JSON only where the server's own value is JSON — affinity metadata,
docs/user_guide/affinity.md:129. Under D8 the server stores
shapes as rows, not as a document, so a JSON argument would be a
shape of its own invention.
Left to phase 6. The option letters, where the shape parser lives
and whether it is shared with the diskspec parser, the version floor
the collection's requirements.txt asks for, and the client's own
docs/namespace-claims.md page that the operator guide links to.
D12 (Q8): whether C is retired once A ships¶
Decision. The placement dry run (C) is kept once A ships. It
is a general query, not scaffolding.
The caller table says so. Of the ten callers in the master plan's
Callers (G2) table, eight get something from C and only the
conductor's runner path and the ci-images builder get little or
nothing. Three of those uses are ones A cannot serve at all: the
operator building a cloud by hand wants "would this fit?" before
ten creates, 33fl's static-runners wants it as a pre-flight task,
and lame-duck/evacuate wants "fit with node N excluded?" — a
question a hold cannot answer, because booking the room to find out
whether a drain is possible would take the room being asked about.
A answers "is the room mine"; C answers "is there room", and
the second question does not stop being asked once the first is
answerable.
And it is the core's only read-only entry point. C writes
nothing, so it cannot strand capacity, deadlock admission or
desynchronise the reconciler (master plan, phase 2), which is what
makes it safe for private-ci to score in shadow and safe for a
client-side ledger model to poll. The brief's D1 puts C first
for the same reason.
Rejected: retire C once A ships. It would leave the eight
advisory callers nothing, remove the only way to ask the question
without taking the room, and leave the shared set-placement core
with a single consumer — which would make phase 1's extraction
pointless in retrospect.
Left to the implementing phases. Phase 2 owns C's route,
request and reply shapes and its capability token; phase 6 owns its
client verb. Neither re-opens whether it exists.
Cross-check against the placeable-claims brief¶
Each decision above was read against the brief's D2 (what a hold
promises), D3 (consuming a hold) and D8 (a node that leaves
service).
D7against briefD3. Consistent, and narrower in four places the brief left open: the reference is a uuid resolved namespace-scoped, the unhonourable explicit reference is a409with a reason token, an expired claim's holds are released rather than displaced, and a shrink releases holds rather than being refused. The brief's rejections (exact matching, first fit, release on delete, a silently falling-back reference) are untouched.D8against briefD3. Consistent on substance, with one correction stated in the decision: the hold-sum invariant is a new SQL aggregate in the claim transaction, not a term in_claim_capacity_guard(), which tests cluster totals and whose docstring warns against adding to its migrated term. The brief's intent is honoured; its naming of the guard is not.D8also supplies the column the brief'sD2narrowings need on the live check's own hot path.D9against briefD8.D9answers the question the brief explicitly delegated, and extends the brief's state list witherroranddeletedwhile takingdegradedout. The brief's re-place-or-displace rule, its displacement semantics and its four rejections stand unchanged;D9only gives the rule a concrete trigger that the reconciler already computes.D10against briefD8. The brief listed a lame-duck node among the cases but marked it "not built"; the deferral agrees and names the one DDL constraint that keeps it cheap later.D11andD12. Neither touches the brief'sD2,D3orD8.D12agrees with the brief'sD1.
No decision here contradicts the brief. The two corrections — the
guard named in brief D3, and the state list in brief D8 — are
both places where the brief's own text points at this plan for the
answer, so neither document needs to yield; both are recorded so
the brief's readers are not misled by them.
The Q1 and Q2 verdict¶
Written 2026-11-07 against develop at 32de6cec7f. The inputs are
the measurement subsection above, D7–D12, and shakenfist/private-ci
docs/plans/PLAN-placeable-claims-phase-04a-regression.md (F8 and
D5), read at that repository's origin/master.
D13 answers Q1, D14 answers Q2, and D15 records the
constraint placeable-claims phase 4a referred inward to this plan. The
measurement subsection deliberately stopped short of both verdicts
("What this section deliberately does not say"); this is where they are
made, so each of Q1 and Q2 is answered exactly once in this
document.
D13 (Q1): A proceeds, live memory does not come first¶
Decision. The master plan's order stands unamended: measure, then
C, then A. The live-memory question is not sequenced ahead of A,
because live memory is no longer refusing at anything like the share
Q1's test was written against — it has fallen from a third to a few
percent, and in 0a's per-sample attribution the live check was the
sole reason a node was dropped once in 4968 node-samples. The question
is deferred rather than dismissed: phase 2's shadow counts, which C
was already going to produce, re-apply Q1's own test before phase 3
is written, and D14's retune is the one foreseeable event that could
reopen it.
Q1's test, applied to the same quantity in three windows. Q1 asks
whether live-memory refusals run "at anything like the baseline's live
share", and names the baseline figures. Memory-stage node-drops by
reason:
| Family | Window | KSM | Ledger | Live | Live share |
|---|---|---|---|---|---|
| in-job | baseline, to 2026-10-17 | 32 | 12 | 24 | 35.3% |
| in-job | 2026-10-30 to 11-06 | 937 | 377 | 56 | 4.1% |
| in-job | 2026-11-01 to 11-06 | 538 | 281 | 11 | 1.3% |
| runner-create | baseline, to 2026-10-17 | 109 | 115 | 229 | 50.6% |
| runner-create | 2026-10-30 to 11-06 | 514 | 228 | 76 | 9.3% |
| runner-create | 2026-11-01 to 11-06 | 468 | 179 | 65 | 9.1% |
The baseline rows are the master plan's own (Q1: 24 of 68 in-job and
229 of 453 runner-create memory node-drops were live). The 7-day rows
are placeable-claims phase 4a's F8; the 5-day rows are its "Why each
node was dropped, latest segment" table. Disk-bandwidth drops are
excluded from every row, because Q1's denominator is memory
node-drops. Both later windows are given because they are not the same
window and the share is worth reading at each: 4a's own narrative
figure — insufficient memory fell from 138 in the middle segment
to 11 in the latest — belongs to the 5-day rows.
A 35.3% live share becomes 4.1% and then 1.3%; a 50.6% share becomes
9.3% and then 9.1%. The absolute counts of the other two reasons rose
by an order of magnitude over the same period. That is Q1's test
failing to trigger, on Q1's own terms, on either of the later
windows.
Why the baseline and the later windows disagree, which matters more
than the arithmetic. They are not equally good reads of the same
thing. The baseline's
post-change window is 1.31 days, and it ends on 2026-10-17 — the
day private-ci's phase 1 stopped emitting the Error in main loop line
that tools/measure-placement-refusals.py joined the runner-create
family on. Phase 4a's F3 and F5 establish that every reading taken
after that date read zero runner-create refusals where the truth
was first eight and later seventy a day, until 4a repaired the join.
So the baseline's runner-create row is the last one the old instrument
could produce, taken over a day and a third, and the later rows are
seven and five days taken with the repaired instrument and cross-checked
against conductor_runner_create_refused_total to within 2%. Where
they disagree, the later ones are the better measurement, and this
verdict rests on them.
What 0a's measurement adds, and it is the independent half. Phase 4a's counts are of refusals the scheduler actually emitted; 0a's are of what each sub-check would have done to a 16384 MB shape at every node-sample in 72 hours. The two are methodologically independent and agree: the committed ledger refuses in 789 of the 790 node-samples where anything refuses, and the live check is the sole refuser in 1 of 4968 (2 of 7911 at 60 s on the busiest day). The live check is the loosest of the three sub-checks almost always.
Which of those conclusions survive the used_memory_mb proxy, and
which do not. 0a's ledger figures use memory_total_instance_max as a
proxy for used_memory_mb, and the proxy can only undercount, so proxy
ledger room is an upper bound on real ledger room. For this verdict
that bias runs the safe way on every load-bearing figure:
- Robust. The live check's own refusal count (65 of 4968) is computed from gauges alone and does not touch the proxy at all.
- Robust. "Live is the sole refuser in 1 of 4968" is an upper bound: correcting the proxy can only make the ledger refuse more often, which can only reduce the set of samples in which live refuses alone. The same holds for the ledger being the tightest of the three.
- Not robust, and not relied on here.
D14's 27.2% is an upper bound for the same reason (seeD14), and 0a's "at no sample did every node refuse the shape" is explicitly not treated as evidence by 0a itself.
No conclusion in D13 needs ledger room to be large, which is the
direction in which the proxy would mislead.
What would falsify this. Stated so phase 2 and phase 3 can check it rather than inherit it:
- Phase 2's shadow counts are the re-test. If
Canswers "fits" and the create minutes later is refused oninsufficient memoryat anything like 24 in 68 or 229 in 453,Q1is reopened and the live-memory question is sequenced ahead of phase 3. That is the testQ1wrote and the master plan's phase 2 already commits to reading those counts before phase 3. D14's retune is the one foreseeable event that could flip this. Loweringnode_ram_reservation_gbraiseslimit_memory_mbone-for-one and relaxes the check that is actually binding. Relieve the binding check and whichever check is next becomes binding; on sf-4 and sf-6 the live check already refuses a 16 GiB shape on 2.7% and 1.2% of samples. So a reservation retune on sfcbr must be followed by a re-read of these shares before phase 3 is written, and the retune's own effect is the reasonD13defers the question instead of closing it.- Moving
RAM_OVERCOMMIT_RATIOoff 1.0 invalidates the algebraD14rests on (the two reservation subtractions no longer cancel), so the wholeQ1/Q2reading would need retaking. 33fl's reasoning against moving it stands and nothing here asks for it. - A different cluster or a different shape. Everything above is
sfcbr, at ratio 1.0, against a single 16 GiB shape, with
memory_total_instance_max - memory_total_instance_actualmeasuring exactly zero at every percentile. A deployment where guests balloon, or where host non-guest use approaches the reservation, would read differently.
Rejected: sequence the live-memory question ahead of A. It would
buy a narrowing worth about one node-sample in five thousand, and it
would do so ahead of the mechanism that addresses the refusals actually
being counted — which are allocation-cap refusals and, in 374 of 461
in-job cases in phase 4a's latest segment, packing failures. Phase 4a's
D5 is explicit that waiting does not defragment and that the only
mechanism in sight that can defend a large-shape slot is a hold. Making
the smallest of the three memory constraints the gate on the largest
would invert the priority the measurement establishes.
Rejected: close Q1 outright rather than deferring it to phase 2.
One of the falsifiers above is something this plan's own hand-off can
cause — D14's retune — and one risk is not in that list at all,
because 0a cannot see it: a transient inside the 300 s sampling
interval, which the gauges smooth over, is exactly what the live check
would catch and what a retrospective reading cannot count. C's
shadow counts cost nothing extra to read, and they are the only
instrument that sees a live refusal at the moment a create meets
it.
Left to the implementing phases. Phase 2 owns what the shadow
comparison records and how a "fits, then refused live" event is
counted; it does not re-open whether C comes before A. Phase 3 owns
reading those counts before it is written, which the master plan
already requires.
D14 (Q2): what the double-subtraction costs, and where¶
Decision. The host's reservation is subtracted twice on sfcbr, as
F1 said — but the two places are the committed ledger
(limit_memory_mb) and the KSM ceiling, not the ledger and the live
check. At RAM_OVERCOMMIT_RATIO = 1.0 the live check's and the
ledger's reservation terms cancel algebraically, so the live/ledger
margin Q2 asks about was never a function of the reservation at all.
The cost is about 27% of per-node memory refusals of a 16 GiB shape, an
upper bound, incurred through limit_memory_mb; the live-side cost is
worth one node-sample in 4968. The second subtraction is an extra
buffer of the reservation's size rather than a duplicate of a cost the
host is incurring, and whether it is the right buffer is 33fl's
judgement, not this plan's. The retune is handed to 33fl with one
lever, node_ram_reservation_gb, and it is not a gate on A.
Where the arithmetic actually lands. 0a's identity is the whole
answer: at ratio 1.0, ledger room - live room = host_use -
used_memory_mb. The reservation appears on both sides and cancels.
What remains is the difference between what the host has faulted in and
what the ledger has committed, and on sfcbr the second term dominates,
because guests are charged at nominal allocation and
memory_total_instance_max - memory_total_instance_actual is exactly
zero at every percentile on every node. So live room sits
persistently above ledger room — median gap negative on all six
nodes by 1.7 to 6.7 GB — which is the opposite of the signature D4
predicted. Correcting the used_memory_mb proxy would push the gap
further negative, so the finding is not an artefact of the proxy.
With actual == nominal, proxy ledger room is exactly the KSM headroom
less the reservation on every node. That is the second subtraction,
and it is between the ledger and the KSM ceiling.
What it costs. 215 of 790 refusing node-samples (27.2%) are ones the
ledger refuses while both the KSM ceiling and the live check admit,
which is to say refusals that exist only because of the reservation
term in limit_memory_mb. A second 72-hour window gives 27.1% and a
60 s re-run of the busiest day 22.5%. This figure is an upper
bound, for the same reason D13's sole-refuser count is one: a truer
used_memory_mb tightens both the ledger and the reservation-free
ledger it is compared against, so some of those 215 would be refused
either way. The one figure the proxy does not touch is the capacity
withheld — 46680 MB (45.6 GiB) of limit_memory_mb across the six
nodes — because that is the sum of the reservations themselves.
And what it does not cost. The live-side half of the double
subtraction is worth 1 node-sample in 4968: removing it takes
live-check refusals of a 16 GiB shape from 0.0-2.7% of samples to
0.0-0.8%. So Q2's question "how much of the live third is margin and
how much is shortage" has a different answer than it anticipated: by
the time it is asked there is almost no live third left (D13), and
the margin the double subtraction creates sits almost entirely in a
check that is not the live one.
It is not a duplicate of a cost actually incurred. Separating guest
RSS out of memory_max - memory_available with process-exporter, the
host's own non-guest footprint is 0.7 to 3.3 GiB at the median against
a 6.2 to 10.3 GiB reservation, and exceeds the reservation on under
1.5% of samples on every node. So the ledger's subtraction is not
re-charging memory the live check has already removed; it is adding a
second buffer the size of the whole reservation on top of a genuinely
measured host use a third to a quarter of it. That buffer has a
rationale — both 33fl/group_vars/allsf.yml:22-25 and
shakenfist/scheduler.py:796-808 note that the measurements lag
placement — so "duplicate" is the wrong word for it and "is it the
right buffer?" is a tuning question rather than a correctness one.
The hand-off, and why it is not a gate. The lever is the per-host
node_ram_reservation_gb override in the 33fl inventory
(NODE_RAM_RESERVATION_GB, shakenfist/config.py:719-727, templated
at shakenfist/deploy/collection/roles/node/templates/config:29), and
the figure it withholds is the 46680 MB above. Raising
RAM_OVERCOMMIT_RATIO is not the recommended lever: it is a
deliberate OOM defence, placeable-claims phase 4a declined to touch it,
and dropping the reservation from limit_memory_mb would make the
ledger exactly as permissive as the KSM ceiling the same ratio already
sets, which is a far smaller change. The retune is 33fl's (D1, out of
scope) and it does not gate A: it relaxes the binding check,
which makes placement easier, and nothing in A's design depends on
its value. D13's third falsifier is the one coupling in the other
direction — if the retune lands, the live shares must be re-read before
phase 3.
Rejected: treating the 27% as a reason to defer A until the retune
lands. The retune relieves saturation of the allocation ledger; it
does not defragment, and D15 is why. A node whose limit_memory_mb
rose by 6 GB is still a node the ranker will spread a 4 GB guest onto
before a 16 GB one arrives.
Rejected: recording Q2 as "not established", which is where the
master plan left it. It is established in both directions now: the
double subtraction exists and is quantified, and the place Q2
expected to find it is not where it is.
Left to 33fl and the implementing phases. 33fl owns whether and by
how much to retune, and owns re-reading D13's shares afterwards. One
optional ask remains open from 0a: a single SELECT node_uuid,
limit_memory_mb, used_memory_mb FROM scheduler_node_capacity would
replace the proxy with the ledger's own figure and turn both of the
bounds above into exact figures. No conclusion in D13 or D14 waits
on it.
D15: a hold defends a slot for a shape, not an aggregate¶
Decision. This plan carries the constraint placeable-claims phase 4a
D5 referred inward: a hold must defend a slot for a shape on a
node, not a quantity in an aggregate, because the scheduler's node
ranking is deliberately spreading allocation away from the nodes a
large shape needs. Phase 3 owns it — it is a constraint on the node
guard and on what a hold row is — and phase 4 inherits the half about
keeping a booking across re-placement. Nothing here asks for a change
to ranking, and nothing here is in tension with shakenfist#3937.
What the ranker does today, re-read at 32de6cec7f. find_candidates()
buckets each surviving candidate by the worse of two pressures: its
CPU load normalised by schedulable threads, and its committed-memory
fraction, in coarse 0.25-wide bands —
at shakenfist/scheduler.py:1310-1312, with the memory fraction read
from the scheduler_node_capacity row the capacity guard itself draws
down (:1301-1307) rather than from the lagging metrics, and the
reasoning in the comment at :1290-1300. That is shakenfist#3937
(merged 2026-08-28), which closed shakenfist#3636 — "scheduler RAM
gates race placement and ranking ignores RAM, funnelling large VMs onto
the most-committed node". A node near its allocation cap now ranks
worst.
Both behaviours are correct, and they pull apart. Ranking by the
worse of two pressures spreads allocation across the fleet, which is
exactly right for the OOM kills #3636 was filed about: it is what stops
a burst funnelling onto the node that already carries the RAM-heavy,
CPU-idle guests. It is also what converts aggregate headroom into
per-node fragments, and a 16 GiB guest needs its 16 GiB on one node.
Six nodes each 80% allocated at RAM_OVERCOMMIT_RATIO = 1.0 have tens
of gigabytes free between them and no room for a single
12/16384/160. Phase 4a measured that this is what is happening: 374
of 461 in-job and 330 of 347 runner-create refusals in its latest
segment are fragmentation, both surviving its pessimistic reading. So
OOM safety and large-guest admission are both served correctly and in
opposite directions, and nothing in the scheduler reconciles them.
This is not a defect in #3937 and not an argument for changing
ranking; it is the reason a reconciling mechanism has to exist
somewhere else, and A is that mechanism.
The consequence for this plan, stated as a design constraint. A hold that reserved only ledger headroom in aggregate would not keep a node able to accept the shape the hold was taken for: the ranker would refill any gap that opened with whatever create asked next, and the holder would arrive to find its room spread into fragments. So:
- A hold is booked onto a node, for one shape.
D8already stores it that way — one row per hold carrying the three dimensions and the booked node — andD15is why that storage shape is load-bearing rather than merely convenient. A per-claim aggregate held counter would not satisfy this constraint, which is a second, independent reasonD8rejects a namespace-side and a cluster-side held column. - The node guard term is what defends the slot against the ranker.
G6'sused + held + request <= limitfor every create, including the holder's own unheld ones, is exactly the mechanism: the ranker may still prefer the held node, but the guard refuses the create that would eat the booking. The ranker is left alone and the guard does the reconciling. - Displacement must not quietly become an aggregate. When a node leaves
service, a displaced hold is booked on no node (
D9, phase 4), and while displaced it defends nothing — which is why phase 4's rule is re-place first and report the displacement, rather than falling back to an aggregate reservation that would look like a hold and not behave like one.
What this does not claim. It does not claim ranking is wrong, that
3937 should be revisited, or that a comment belongs on #3636 — phase¶
4a deliberately left that issue alone, and so does this. It also does
not claim C can satisfy the constraint: a dry run defends nothing,
by construction, which is D12's point about it writing nothing.
Left to the implementing phases. Phase 3 owns the node guard term and the hold row's DDL, and must not weaken either into an aggregate. Phase 4 owns re-placement and displacement, and owes this decision only that a displaced hold is reported rather than silently kept as an aggregate. Phase 2 owes it nothing.
Consistency pass¶
Each of Q1–Q8 is answered or deferred exactly once in this
document.
| Question | Where | Verdict |
|---|---|---|
Q1 |
D13 |
Answered: A proceeds; re-tested by phase 2's shadow counts |
Q2 |
D14 |
Answered: real, ~27%, in the ledger and the KSM ceiling |
Q3 |
D7 |
Answered |
Q4 |
D8 |
Answered |
Q5 |
D9 |
Answered |
Q6 |
D10 |
Deferred to #1364, with one DDL constraint on phase 3 |
Q7 |
D11 |
Answered |
Q8 |
D12 |
Answered |
The measurement subsection records Q2's figures and says in terms
that it does not answer Q1; D14 is where Q2's verdict is drawn,
so neither question has two answers.
Every D against every other. D1–D6 govern how the phase runs
and D7–D15 are what it produced.
D1(no code) holds: nothing inD13–D15changes the tree outsidedocs/.D2/D3/D4were written for a prospective scrape. 0a found the gauges already in maui's Prometheus and took the measurement retrospectively, withdrawingD3's ask of 33fl. That is a recorded amendment in 0a's subsection, not a contradiction:D2's working-day and CI-busy requirement became a window-selection criterion and was met (the three highest runner-create refusal days in 93), andD4's two comparisons were taken as specified.D4's expectation about what they would show was wrong, which is a result rather than a conflict.D5is satisfied exactly as written:Q1is answered fromD4plus the baseline's live share, not in the abstract.D5's first branch ("the fix is 33fl's retune andAproceeds") is the one taken, with one correction to its premise — the double-subtraction does not explain the live-memory refusal third, because that third has collapsed for reasons unrelated to the reservation.D5's second branch would have amended the master plan's order; it is not taken, so the order is unamended.D6is satisfied byD7–D12, each with its reasoning and rejected alternative, and each naming what it leaves to its implementing phase.D13–D15follow the same form.D7andD15agree:D7's "a hold carries no affinity, and affinity is tested at consumption" is compatible with defending a slot for a shape, because the slot is a node plus three dimensions and affinity is a property of the create, not of the booking.D8andD15agree and reinforce:D15is a second, independent reason forD8's rejection of a namespace-side or cluster-side held counter.D9andD15agree: a displaced hold defends nothing, whichD9already treats as a reportable outcome rather than a silent one.D13/D14againstD10,D11,D12: no interaction.D12's reason for keepingCis strengthened byD13, which givesCa second job — carryingQ1's re-test — butD12does not depend on it.D14againstD8's warning thatheld_disk_gbmust not feed_derive_disk_limit_gb(): both are about not letting a reservation or a hold be counted twice in a derived limit. No conflict; the memory and disk limits are derived by different functions.
One ambiguity resolved. D7 quoted an unqualified D15 from
shakenfist/mariadb.py:30355-30357 ("there is no auto-grow"), which is
another plan's decision number carried in a code comment. With this
plan now having its own D15, that citation has been reworded to name
its source rather than read as a cross-reference inside this document.
No contradiction was left unresolved.
Definition-of-done item 3: the master plan's ordering¶
Read the master plan's Execution ordering prose against D13: it
needs no amendment. Recording that explicitly, because it is a
result and not a non-answer.
- The phase table's order (0, 1, 2, 3, 4, 5, 6, 7) stands.
- "The order follows placeable-claims phase 0's
D1andD9:Cfirst,Asecond, on one core, and the live-memory measurement before either admission change" stands, and is now a statement about work that has been done rather than work planned. - The phase 0 bullet's conditional — "its answer to
Q1/Q2decides whether the live-memory question is sequenced ahead ofA" — has been rewritten in the master plan to record the answer, which is the only ordering prose this verdict moves. It moves it by resolving a condition, not by changing a sequence. - The phase 2 bullet already requires private-ci's shadow counts to be
read before phase 3.
D13makes that requirement load-bearing rather than merely useful, and the master plan now says so.
Execution notes¶
Executed 2026-11-07 (AEDT) by a management session running the step
table through sub-agents, on branch
placeable-capacity-phase-00-decisions from develop at
32de6cec7f.
Gates answered at the back brief. The measurement was taken from
Prometheus history alone, with no sfcbr database or cluster access;
placeable-claims phase 4a's shape-hold constraint was folded into
this phase's decisions rather than raised as a separate issue (it is
D15); and this session was given leave to open the pull request.
The measurement became retrospective, and D3's ask of 33fl was
withdrawn. D2–D4 were written for a prospective scrape of each
hypervisor's :13001 endpoint over a future working day. Every gauge
they name is already in maui's Prometheus with 93 days of history, so
0a chose its window from the past instead. The requirement D2
imposed was still met — the window holds the three highest
runner-create refusal days in all 93 — and the plan's first risk, that
the measurement needs host access this session does not have, does not
arise. D4's expectation was wrong, which is a result and is
recorded as one.
Step order. 0a and 0b ran in parallel, since one reads Prometheus and the other reads the tree, and neither writes the worktree. Each wrote its section to scratch; this session reviewed, assembled and committed one commit per step. 0c then ran its consistency pass against the committed document rather than against loose sections, which is stronger than the plan's description of it.
What the review changed. 0a's figures were reproduced by re-running
its script, which gave 216 reservation-only refusals against its 215 —
sampling drift at the window edge, so the finding is quoted as "about
27%" rather than to three digits. Its cluster-level paragraph read the
proxy's "some node always had room" as support for fragmentation; real
refusals did occur in that window, so that paragraph now says the
proxy is loose there and credits the fragmentation evidence to
placeable-claims phase 4a. D8's visibility bullet named
summarize_resources(), whose own docstring records it as retained
for the test suite and called by no production code; it now names the
shared payload builder, so phase 3 adds the fields where both callers
see them. The brief this session gave 0c mislabelled phase 4a's
latest-segment node-drop table as its seven-day one; 0c caught the
error and D13 records both windows, labelled, because the verdict
holds on either.
Facts corrected at source. F1, F2 and F3's line citations were
re-pointed at 32de6cec7f (_has_sufficient_ram had moved from
:520 to :753, among others). In the master plan, shakenfist#4207
is recorded as closed rather than open at all five places that cited
it, Q7's premise is replaced — the claim verbs shipped in
client-python v0.8.4 and the page it called stale was already
fixed — and Q1, Q2 and Q7 are marked answered by this phase.
Carried out of this phase. An issue still needs filing for the claim refusal contract, deliberately not filed here so that it is written against the design phase 5 ships; phase 5 owns it. The per-host RAM reservation retune is handed to 33fl with its lever and its figure, and gates nothing.