Placeable capacity: placement dry runs and shape holds on claims¶
Prompt¶
Before responding to questions or discussion points in this document, explore the shakenfist codebase thoroughly. Read relevant source files, understand existing patterns (object lifecycle, state machines, MariaDB storage via the three-layer direct/gRPC/public pattern, Pydantic schemas, daemon architecture, the scheduler's candidate filters, the claims and capacity ledger), and ground your answers in what the code actually does today. Do not speculate about the codebase when you could read it instead. Flag any uncertainty explicitly rather than guessing.
Consult ARCHITECTURE.md for the system architecture overview,
AGENTS.md for project conventions and the index into docs/,
and GOALS.md for current development priorities. Key references
inside the repo include shakenfist/scheduler.py (candidate
filters and summarize_resources()), shakenfist/mariadb.py (the
claims and capacity ledger, admit_instance_placement(),
_claim_capacity_guard()), shakenfist/external_api/instance.py
and shakenfist/operations/node_inst_netdesc_op.py (the two
placement walks), and shakenfist/external_api/app.py
(API_CAPABILITIES).
This plan exists to serve callers across several repositories, and
its design input is external. It was commissioned by
shakenfist/private-ci's PLAN-placeable-claims plan, whose phase 0
produced a self-contained design brief — the "Brief for the
shakenfist/shakenfist plan" in
docs/plans/PLAN-placeable-claims-phase-00-decisions.md in that
repository. That brief is this plan's primary input: it restates
every figure, cites the code, surveys ten callers, and refines the
requirements. This document lifts it into a shakenfist/shakenfist
master plan; it does not replace it. Where this plan cites a
decision as "placeable-claims phase 0 D2", that D number is
that plan's, not this one's.
The brief's code citations are into shakenfist develop at
ea39dff7c9 (2026-10-17). This repository has moved since: at the
time of writing develop was at 61a14af571, and line numbers had
already drifted (for example _claim_capacity_guard() moved from
shakenfist/mariadb.py:28044 to :29563, and by phase 0's survey at
32de6cec7f it was :29927). The structural facts hold —
API_CAPABILITIES/advertised_capabilities(),
CLAIM_ENFORCEMENT_HARD = False, admit_instance_placement(),
find_candidates() and the resources summary are all present — but
every phase plan must re-survey its own citations against current
develop rather than trusting the line numbers copied here from
the brief.
Plan file conventions (shared block; do not edit -- the canonical
copy lives in shakenfist/development at
templates/shared-blocks/plan-file-conventions.md):
- All planning documents live in
docs/plans/. - Detailed planning gets one plan file per phase. Phase files are
named for their master plan, sit in the same directory as it,
and append
-phase-NN-descriptivebefore the.mdextension. - The master plan tracks its phases in a table under its Execution
section.
Mergedis last, and the push audit is the last row, for the reasons given inplan-push-audit-phase:
| Phase | Plan | Status | Merged |
|---|---|---|---|
| 1. Schema migration | PLAN-thing-phase-01-schema.md | Not started | |
| 2. Public API | PLAN-thing-phase-02-api.md | Not started | |
| 3. Push audit | - | Not started |
- One commit per logical change, and at minimum one commit per phase. Unrelated changes are not batched into a single commit. Each commit is self-contained: it builds, passes tests, and has a message explaining what changed and why.
This is a master plan. Each phase's steps are written as a phase
plan with /next-phase once the phase before it has landed,
because the per-node admission changes depend on what the placement
dry run measures (the live-memory question below), and because each
phase's survey depends on what the last actually shipped.
Situation¶
Written 2026-11-04. A Shaken Fist claim reserves aggregate cluster capacity; the scheduler places one instance onto one node at a time, and never reads a claim. So a granted claim is a promise about sums, and a set of instances that fits the sum can still fit on no single node. On sfcbr this stopped being a theoretical case on 2026-10-16, when a memory overcommit change made the cluster's residual room per node smaller than the instances CI builds.
The authority for everything in this section is placeable-claims phase 0's brief and its baseline; the figures are restated here so this plan stands alone, but the brief has the working.
S1. A claim admits a sum; the scheduler places per node¶
- Claims are aggregate.
_claim_capacity_guard()(shakenfist/mariadb.py) admitsclaimed + requested + GREATEST(0, unclaimed_used - migrated) <= totalper dimension. No table ties a claim to a node —scheduler_node_capacity,namespace_claimsandcluster_capacityhave no node-scoped claim column. That is scheduler-reservationsD14, by design. - The scheduler never reads claims.
find_candidates()(shakenfist/scheduler.py) filters each node on CPU, memory, free disk, disk bandwidth and queue depth, and has no reference to claims._has_sufficient_ram()runs three checks and stops at a node's first failure: livememory_availableless the reservation; the KSM ceiling(memory_total_instance_actual + requested) / memory_max > RAM_OVERCOMMIT_RATIO; and the committed memory ledger. Two of those read live state that no ledger entry can promise. - Claim limits are advisory.
CLAIM_ENFORCEMENT_HARDisFalse(shakenfist/mariadb.py), so nothing stops another namespace placing beyond its claim, and unclaimed instances take physical room the ledger cannot hold back. - Placement happens twice.
POST /instanceswalks candidates throughplace_instance()and answers 507 if none admits (shakenfist/external_api/instance.py); preflight then re-runsfind_candidates()forced to the chosen node, redirects if it refuses, and aborts with a prose reason — no 507 — if no node admits (shakenfist/operations/node_inst_netdesc_op.py). The two walks must change together. - Refusal contracts differ. An instance 507 carries
stage,transientandRetry-After: 15(capacity_error(),shakenfist/external_api/base.py). A claim 507 is prose with none of them:_claim_refusal()(shakenfist/external_api/auth.py) folds the per-dimension detail into the message and returns a baresf_api.error(). No open issue tracks this. #4207 was the issue cited here; it was closed on 2026-10-29 by the transient-capacity-refusals plan, which gave the instance 507 its contract and left the claim 507 alone. The requirement stands (G7, phase 5) and needs an issue of its own filed against it.
S2. What changed, and what fragmentation looks like¶
sfcbr's RAM_OVERCOMMIT_RATIO went from 3.0 to 1.0 at 08:36 AEDT
on 2026-10-16 (mach33labs/33fl group_vars/allsf.yml), after
kolla guests dirtied unique pages KSM could not recover and sf-6
took OOM kills. That cut every node's ledger memory limit and KSM
ceiling to physical memory. Scheduler refusals went from one to
seven a day to 93 in one hour.
In one walked case the conductor's namespace claim was granted at 23:34:19.005Z and its CI runner (8 vCPU / 16384 MB / 460 GB) was refused at 23:34:24.933Z, with 14,722, 12,759 and 12,428 MB free on the three nodes that reached the memory stage: 39,909 MB in sum against 16,384 asked, and no node that fit. A granted claim is a promise about sums.
S3. The baseline splits fragmentation from saturation¶
Read from sfcbr's scheduler audit stream by private-ci's
tools/measure-placement-refusals.py, over 2026-10-11 08:36 to
2026-10-17 16:00 AEDT (five days before the overcommit change, 1.31
days after). Fragmentation: room in sum, no node fit.
Saturation: no room in sum.
| Family | Before | After | After, by class |
|---|---|---|---|
| CI runner create refused after its claim was granted | 0 | 196 (149.8/day) | 190 fragmentation, 6 disk bandwidth |
| A CI job's own creates (nested test clouds) | 8 (1.6/day) | 37 (28.3/day) | 34 fragmentation, 3 disk bandwidth, none saturation |
| Accepted, then aborted at preflight | 0 | 1 | |
| Claim refused by the aggregate guard | 817 (cpus) |
18,669 (memory_mb 18,111) |
all saturation |
ucs-lab-* (standing, unclaimed) |
1 | 10 | 5 fragmentation, 5 cluster guard |
The refusals this plan addresses are fragmentation: a placeable primitive buys back room present in sum but on no single node. It cannot create memory for saturation, which is claim-coverage's right-sizing problem. Over the window 40 of 45 in-job builds were single-instance. Time to room after a fragmentation refusal: median 20 minutes, 90th percentile 65, longest proven 106; a one-minute retry would have rescued none of 42.
Mission and problem statement¶
When this plan has landed:
- Shaken Fist offers a placement dry run (
C): a stateless, advisory "would these shapes fit now, and where?", in Shaken Fist's own vocabulary, behind a capability string, useful to every caller and to a client-side ledger model. - Shaken Fist offers shape holds on a claim (
A): a claim can carry a multiset of instance shapes, placed onto real nodes when the claim is made and kept booked, behind a second capability string. A create in that namespace consumes a matching hold without being told to, so an unchanged playbook gains the guarantee. - A hold promises "not refused because another namespace took the
room" — the ledger checks and, through the memory ledger, the KSM
ceiling. It does not promise a node's live idle memory or disk
bandwidth, so callers keep a bounded wait of their own
(placeable-claims phase 0
D2). - Both rest on one shared set-placement core: a multiset walked through the scheduler's own filters, all or nothing, each shape's tentative charge visible to the next.
- Aggregate claims behave exactly as they do today. The feature is
additive and advertised, so an old client or server degrades to
today's behaviour. Gang create (
B) is not built. - The design is checked against the ten callers placeable-claims
phase 0 surveyed (
G2), not only the conductor. At least one non-conductor caller uses it or is recorded as checked against it.
What the primitive must be (requirements)¶
These are the requirements placeable-claims phase 0 refined from
the G1–G8 list in its master plan. They are the acceptance bar
for the design, carried here so a phase plan need not leave the
repository to find them.
- G1. Shaken Fist's vocabulary. A shape is what
POST /instancestakes:cpus,memoryand the disk list, matched on the summed disk size. No runner, job, label or topology concept crosses the API. - G2. Every caller, checked by table (see Callers below), not only the conductor.
- G3. Unchanged creates benefit. A create in a namespace with
unconsumed holds consumes, without being told, the smallest hold
it fits within on every dimension (best fit, ordered by memory,
then vCPUs, then disk), on a node satisfying any hard affinity it
has. An optional hold reference on
POST /instancestakes that hold or is refused with a non-capacity, non-transient error naming why — never a silent fallback. A create larger than every hold is an unheld create. Exact matching is rejected: disk layouts, optional cache disks and measured peaks all drift. - G4. Placement policy in the existing affinity model
(
docs/user_guide/affinity.md),prefer_*/require_*, not the deprecated weighted form. - G5. Additive and advertised. One capability string for
Cand one forA. No new refusal stage names: a held create's refusal adds fields rather than minting stages, so callers that switch onstagekeep working. - G6. A hold binds other namespaces whatever claim enforcement
says. The node guard becomes
used + held + request <= limitfor every create, the holder's own unheld creates included; a consuming create excludes its hold and moves it from held to used in one statement. That is the node guard's switch, notCLAIM_ENFORCEMENT_HARD's, and the two stay apart. - G7. Machine-readable refusals. A hold that cannot be booked is
a 507 naming the shape and stage, transient by the same sets as
capacity_error(). A held create refused on a live check gets that check's stage,transient: true,Retry-After: 15, and fields naming the hold and saying it was retained. Claim refusals gain the contract too — seeS1: the issue that used to track that gap (#4207) is closed and an issue still needs filing. - G8. Inspectable. Held room per node, consumed and unconsumed,
and displaced holds with the node they left and why, in the
claim's view and in
GET /admin/resources;summarize_resources()must net holds out, or every client-side ledger model counts booked room as free.
What a hold promises, and what it does not (D2)¶
A held create still runs the scheduler's live checks — they are the only guard against the OOM kills that set the ratio to 1.0, so skipping them for held creates is rejected. A hold promises the ledger charges (committed vCPUs, memory ledger) and, through the memory ledger sitting below it, the KSM ceiling while the ratio stands. It does not promise live idle memory, free filesystem disk, or the disk-bandwidth and queue-depth load-shedding stages. Two narrowings reduce the live gap without pretending to close it: every other create's live check counts a node's unconsumed holds as if resident, and a hold's own booking counts memory placed but not yet running. What stays out of reach is resident-set growth and the host's own use — which is why callers keep their own bounded wait.
Callers (G2)¶
The conductor is one caller of ten. The design is checked against
all; placeable-claims phase 0's P4 table has the judgement.
| Caller | Shapes (vCPU/MB/GB) | Lifetime | Gets from A |
Gets from C |
|---|---|---|---|---|
| private-ci conductor, CI runners | 2/4096/100, 4/8192/200, 6/12288/300 (+60 GB); ~10 per pass | one job; claim re-dated every 15 min | runner + job shapes booked before the runner boots | "would it fit?" before making a namespace |
| private-ci ci-images builder | 2×4/4096/100 (+50/+40), never together | standing claim, nightly | room kept across delete and recreate | little |
33fl static-runners |
8×4/8192/150, spread | 30-day claim, rebuilt weekly | room kept across the weekly rebuild | a pre-flight task |
33fl static-ci (k3s) |
4/4096/50 control, 4/8192/50 worker | 30-day claim; future scaler | both nodes booked | advisory |
| shakenfist/actions CI topologies | localhost 1×12/16384; slim-tier 3×6/12288; slim-primary 1×4/4096 + 5×4/12288 |
one job | draw on the runner's claim; the conductor holds | advisory |
| kerbside lanes | 1×12/16384/400 | one job | as above | advisory |
| kerbside-patches | 1×12/16384/400, or 8 instances 34/55296/3200 | one job, bound | as above | advisory |
| client-python-k3s CI | peak 13/13312/150; churns within a job | one job | holds that return on delete fit its churn | advisory |
| operator building a cloud by hand | whatever is typed | hours–days | "book it, build at leisure" (scheduler-reservations Q17) |
"would this fit?" before ten creates |
| lame-duck / evacuate (#1364, not built) | the source node's instances | one drain | the per-node mechanism, but needs an admin-scoped hold | "fit with node N excluded?" |
A is the only candidate under which an unchanged create path
gains: the holder changes, the creators do not. A design that only
the conductor can use is the failure G2 exists to catch.
Open questions¶
Load-bearing for the phase table:
- Q1. Does the live question come first? The plan's order is
measure, then
C, thenA. Phase 0 readsC's shadow counts beforeA's admission changes are written: ifC's "fits" becomes a live-memory refusal at anything like the baseline's live share (24 of 68 in-job and 229 of 453 runner-create memory node-drops were live after the change), the live-memory question is sequenced ahead ofA. Phase 0's sfcbr measurement (below) is what answers this. Answered by phase 0 (D13), in that phase's Results: no —Aproceeds and this plan's order is unamended. The live share of memory node-drops has collapsed to 4.1% in-job and 9.3% runner-create over 2026-10-30 to 11-06, and to 1.3% and 9.1% over 2026-11-01 to 11-06; and in phase 0's per-sample reading the live check was the sole reason a node was dropped once in 4968 node-samples. The question is deferred, not closed: phase 2's shadow counts re-apply this test before phase 3 is written. - Q2. Is the live/ledger memory margin double-counted? The live
check subtracts the reservation from psutil's
available(already net of host use); the ledger subtracts it from physical memory. On a ratio of 1.0 the host may be counted twice. Answered by phase 0 (D14), in that phase's Results: the double subtraction is real, but not between the live check and the ledger. At a ratio of 1.0 those two reservation terms cancel algebraically, so the live/ledger margin was never a function of the reservation; the two places the reservation is subtracted are the committed ledger and the KSM ceiling. It costs about 27% of per-node memory refusals of a 16 GiB shape on sfcbr, an upper bound, all of it throughlimit_memory_mb, and about one node-sample in 4968 through the live check. The retune is handed to 33fl with one lever, per-hostnode_ram_reservation_gb, and it does not gateA; the only coupling runs the other way, in that a retune relaxes the binding check and so requiresQ1's shares to be re-read. - Q3. The hold reference format and lifetime, and the consumption
rule's edges (
D3): what aPOST /instanceshold reference looks like, when a deleted instance's hold returns and for how long, and what happens to a create larger than every hold. - Q4. The per-node held column's shape. A ledger dimension
beside
used_*, its reconciler recomputation, and how claim migration carries bookings. This is the real costAadds and the schema decision the plan turns on. - Q5. Whether
degradednodes stop placement (D8). Node states today aremissing,stopping,stopped,degraded; which of them stop accepting holds, and the re-place-or-displace rule when a node leaves service.
Refinements:
- Q6. The admin-scoped hold for evacuate, designed with #1364 rather than guessed at here.
- Q7. How
sf_claimandsf-clientexpress shapes, and the stale statements that no release carries the claim verbs. Answered by phase 0 (D11). The premise this question was written on has since changed twice over: the claim verbs are released — client-pythonv0.8.4was tagged on 2026-10-28 — anddocs/operator_guide/scheduler.md:445-449has already been corrected to say the verbs needshakenfist-client0.8.4 or later, so the statement named here is no longer stale. The statements that are still stale, and are therefore what phase 6 fixes, aredocs/user_guide/ansible.md:24-30,shakenfist/deploy/collection/requirements.txt:16-20andshakenfist/deploy/collection/plugins/modules/sf_claim.py:27-32and:275-302, all of which still tell the operator to install the client from git. - Q8. Whether
Cis retired onceAships or kept as the stateless query it also serves other callers as. The expectation is kept:Cis a general query, not scaffolding forA.
Execution¶
Plan status vocabulary (shared block; do not edit -- the canonical
copy lives in shakenfist/development at
templates/shared-blocks/plan-status-vocabulary.md):
A status cell -- in the master plan's own Execution phase table, and
in the row docs/plans/index.md carries for the plan -- holds
exactly one of these terms and nothing else:
Proposed-- written down as a concept, not yet scheduled.Not started-- scheduled, but no work has begun.In progress-- work has begun and has not finished.Blocked-- cannot proceed until something outside the plan changes. Say what, in the plan.Complete-- the work is done.Abandoned-- deliberately dropped without being done.Superseded-- replaced by another plan, which the plan names.
The term is the whole cell. No dates, no phase arithmetic, no parenthetical qualifiers, no summary of what happened: a status is read to decide whether a plan still wants attention, and prose in that column has repeatedly grown until it could no longer be read either by a person scanning the table or by tooling. Detail belongs in the plan file, and a one-line summary belongs in the index's own Intent column.
Matching is case-insensitive, so In Progress is accepted, but the
spelling above is the one to write.
The Merged column is last, and records what put each phase on
develop for the push-audit phase to read. A phase that has not
landed reads —. Phases that land in client-python record
client-python <sha> (#pr) and are audited there.
| Phase | Plan | Status | Merged |
|---|---|---|---|
| 0. Decisions and the live-memory baseline | PLAN-placeable-capacity-phase-00-decisions.md | Complete | ab19cf67e7 (#4822) |
| 1. Extract the shared set-placement core | PLAN-placeable-capacity-phase-01-placement-core.md | Complete | d20a7d7516 (#4835) |
2. The placement dry run (C) |
PLAN-placeable-capacity-phase-02-dry-run.md | Complete | e02a35e514 (#4850); review round #4860, merge commit recorded at phase 3's close-out; client-python 4ff8a7d (#448) |
3. Shape holds: schema and the node guard (A) |
PLAN-placeable-capacity-phase-03-holds-schema.md (to be written) | Not started | — |
| 4. Shape holds: consumption, release and displacement | PLAN-placeable-capacity-phase-04-holds-lifecycle.md (to be written) | Not started | — |
| 5. The refusal contract | PLAN-placeable-capacity-phase-05-refusal-contract.md (to be written) | Not started | — |
| 6. Client, modules and documentation | PLAN-placeable-capacity-phase-06-client-docs.md (to be written) | Not started | — |
| 7. Push audit | Phase 7 section below | Not started | — |
The order follows placeable-claims phase 0's D1 and D9: C
first, A second, on one core, and the live-memory measurement
before either admission change.
- Phase 0 resolves the open questions above and takes the sfcbr
live-memory-against-ledger reading (
D9). It ships no production code. It gated the sequence, and the gate is open: itsD13answersQ1by leaving this order as it stands, so the live-memory question is not sequenced ahead ofA. ItsD14hands 33fl a per-host-reservation retune, which 33fl owns and which is not a prerequisite for any later phase. ItsD15carries in a design constraint from placeable-claims phase 4a: a hold must defend a slot for a shape on a node, because the scheduler's node ranking deliberately spreads allocation away from the most committed nodes (shakenfist#3937, correctly), which is what turns aggregate headroom into per-node fragments. Phases 3 and 4 own that constraint. - Phase 1 generalises the single-instance placement
pipeline into one all-or-nothing multiset walk through the
scheduler's filters, with no behaviour change. It no longer
de-duplicates anything: the walk this plan was written to
extract was shared by scheduler-reservations phase 5 as
Instance.place_on_candidates()on 2026-11-05, the day after this plan was written, so what is left is the shape denomination and the tentative charge. BothCandAbuild on it. See the phase plan'sF1. - Phase 2 builds
Con that core: a stateless, advisory query behind its own capability string, surfaced in the API and in a client method, but not inGET /admin/resources-- see the section below. No writes, so it cannot strand capacity, deadlock admission or desynchronise the reconciler. private-ci scores it in shadow against what jobs' creates met minutes later, in a harness it has already shipped; those counts are read before phase 3. Phase 0'sD13makes that reading load-bearing rather than merely useful: it is whereQ1's live-memory test is re-applied, so a live share anywhere near the baseline's reopensQ1before phase 3 is written. The reading is private-ci's and gates phase 3, not phase 2. - Phase 3 is gated, and the gate is recorded rather than
remembered: it may not be planned until private-ci has switched its
shadow fit check from a local model to a call to the dry run,
soaked it and read the agreement counts, which is where
D13's live-memory question is re-tested. The entry isplan-blockersb4against this plan. Phase 3 then gives the core the right to book: the per-node held column, the node guard term (G6), claim-limit validation against the sum of holds, and the reconciler and release paths. Phase 0'sD15constrains it: a hold row books one shape onto one node and the node guard is what defends that slot, so neither may be weakened into an aggregate reservation. - Phase 4 is the hold lifecycle: implicit best-fit and explicit
consumption (
G3), holds that return on delete for the claim's life, re-placement in the same transaction or displacement when a node leaves service (D8), and ground-truth overbooking. A displaced hold defends no slot, which is why phase 0'sD15has it reported rather than quietly retained as an aggregate. - Phase 5 gives claim refusals the
stage/transient/Retry-Aftercontract (no open issue; seeS1), adds the held-create refusal fields (G7), and states the split of claim denials out of the transient stages thatCLAIM_ENFORCEMENT_HARDflipping would require, keepingG6's node-guard switch apart from it. - Phase 6 adds the
sf_claim/sf-clientshape verbs and the affinity expression (G4), writes the operator and user guides, and fixes the stale claim-verbs documentation (Q7). - Phase 7 runs
PUSH-AUDIT.mdover the whole plan's diff.
Phases 3, 4 and 5 change what the ledger and the admission guards do, and are high effort. Phase 1 is a behaviour-preserving refactor of a correctness-critical path and is high effort for that reason. Phase 2 is high effort for the API and capability surface; phase 0 is high effort because it is the design. Phase 6 is medium where it mirrors existing client verbs and doc pages.
Phase 0: decisions and the live-memory baseline¶
Answer Q1–Q8, or say which are deferred and why. Take the
measurement placeable-claims phase 0 D9 specifies: on each sfcbr
hypervisor, sample together over at least a working day
memory_available, memory_max, memory_reserved_mb, the nominal
allocation and the guests' summed resident memory
(shakenfist/daemons/resources/main.py), and compare host use with
the reservation and live room with ledger room per node. 33fl
provides node access and owns any retune that follows. The phase
ends with the design decisions the later phases cite, and with a
clear statement of whether the live-memory question comes before
A.
Phase 1: extract the shared set-placement core¶
This section originally read that POST /instances and preflight
run two placement walks that "must change together", and said to
extract them. They no longer do. scheduler-reservations phase 5
shared the walk as Instance.place_on_candidates() in
036f255758 on 2026-11-05, one day after this plan was written,
and left each caller only its own candidate construction and
after-handling, which is the right split. The phase plan's F1
records the correction and this paragraph was rewritten from it.
What remains is the part that was always the point. The placement
pipeline evaluates one Instance: build one function that walks a
multiset of requested shapes through the scheduler's own filters,
all or nothing, each shape's tentative charge visible to the next,
with no behaviour change and the existing single-instance callers
routed through it at one shape. This is the core both C and A
reuse, and doing it first keeps the dry run and the holds from
forking the placement logic.
Phase 2: the placement dry run (C)¶
Build the stateless query on the core: given a multiset of shapes,
answer whether they fit now and on which nodes, with no state
written. Advertise it with a capability string (API_CAPABILITIES
in shakenfist/external_api/app.py) and surface it in a client
method. Not in GET /admin/resources: that was this section's
original instruction and it cannot be followed literally, because a
per-node verdict is a verdict about a shape and a GET with no body
has none to be about. The phase plan's D6 records the decision
and what was rejected with it. It is racy and advisory by
construction.
private-ci scores it in shadow against what a job's creates
actually met minutes later -- which measures how often room
present at claim time is gone at create time, and on which check.
That harness is not phase 2's to build: placeable-claims phase 3
shipped it already, scoring a client-side model of the same
per-node checks rebuilt from GET /admin/resources, so what phase
2 does is let that model become a call. The switch, the soak and
the reading of the counts belong to private-ci and gate phase 3
rather than phase 2 (the phase plan's F1 and D9). Read those
counts before writing phase 3.
Phase 1's core returns a whole ordered candidate list per shape but
charged only the head of each, and phase 2 is the first caller which
could have more than one shape to place. A caller which walks past a
head — which Instance.place_on_candidates() does today when
admission refuses it — leaves the set's arithmetic behind, because
every later shape was ranked as though the skipped shape were on the
node it skipped. Phase 1 states that rule in the entry point's
docstring rather than enforcing it in the return type, on the grounds
that the type to return is the one phase 2's real consumer wants.
Decide it there: either phase 2 takes only heads, or the core returns
the chosen node distinctly from its fallbacks.
Two costs to measure there rather than guess at here. The core calls
add_event_multi() around fourteen times per shape and builds each
extra= payload — affinity_detail, load_detail, by_affinity,
the per-candidate weights — before the call, whether or not the
caller gave it any event targets. A dry run gives none, so all of
that is assembled and discarded, per shape and per candidate. It is
dict construction with no I/O, so there is nothing to win until a dry
run is measured as hot; if it is, guard the construction once at the
top of _find_candidates_for_shape() rather than at fourteen call
sites. And a refused set leaves the audit events of the shapes it had
already assigned, which phase 3's hold evaluation has to decide what
to do about.
Phase 3: shape holds — schema and the node guard (A)¶
Add a claim's ability to carry shapes and book them onto nodes. The
schema decision (Q4) is the per-node held column beside used_*:
its shape, its reconciler recomputation from hold rows, and how
claim migration carries bookings. The node guard becomes used +
held + request <= limit for every create (G6), a consuming
create excluding its own hold in the same statement; this is the
node guard's switch, kept apart from CLAIM_ENFORCEMENT_HARD. Claim
limits are validated, at creation and update, to be at least the sum
of the claim's holds. The claim branch of
admit_instance_placement() is otherwise unchanged, since holds sit
inside claimed.
Phase 4: shape holds — consumption, release and displacement¶
The lifecycle (D3, D8): a create consumes the smallest hold it
fits within (best fit, memory then vCPUs then disk) on a node
meeting its hard affinity; an optional explicit hold reference takes
that hold or is refused, never silently falling back; a deleted held
instance returns its hold, booked where it last was, until the claim
is deleted, shrunk below it or expires; a refused create consumes
nothing; a moved instance takes its booking with it. When a node
stops accepting placements its unconsumed holds are re-placed onto
another node in the same transaction if they fit (most recently
booked first), otherwise displaced: inside the claim's aggregate
limits, booked on no node, reported, an event on the claim — never
left on a node that cannot host them, never dropped, never re-tried
by the server. The holder re-books. Ground-truth writers that admit
with enforce=False may book past holds; ground truth wins and the
node is then overbooked, which the reconciler records.
Phase 5: the refusal contract¶
Give claim refusals the stage/transient/Retry-After contract
instance creates already have (capacity_error()). This phase opens
by filing the issue for that gap: #4207 used to track it and was
closed on 2026-10-29 having fixed only the instance side, so the
requirement is live and untracked (S1). A held
create refused on a live check gets that check's stage, transient:
true, Retry-After: 15, and fields naming the hold and saying it
was retained (G7). No new stage names (G5). State the split of
claim denials out of the transient stages that flipping
CLAIM_ENFORCEMENT_HARD would require (scheduler-reservations phase
5's territory), so the two switches do not get conflated.
Phase 6: client, modules and documentation¶
Add shape expression to sf_claim/sf-client (Q7, phase 0's
D11) and the affinity model (G4, prefer_*/require_*).
Document both capabilities in the operator and user guides,
including GET /admin/resources' new fields and the affinity
semantics. Correct the stale claim-verb statements, which are not
in docs/operator_guide/scheduler.md — that page was fixed before
phase 0 read it — but in docs/user_guide/ansible.md:24-30,
shakenfist/deploy/collection/requirements.txt:16-20 and
shakenfist/deploy/collection/plugins/modules/sf_claim.py:27-32
and :275-302: each still says no release carries the verbs and
tells the operator to install client-python from git, where v0.8.4
(2026-10-28) carries them. _require_claim_verbs() itself stays — it
is a probe against the client that was actually built — and only its
prose changes. docs/operator_guide/database.md covers the schema
change.
Phase 7: push audit¶
Run PUSH-AUDIT.md over the accumulated diff of every phase against
develop, reading each phase's Merged cell for its range. Phases
that landed in client-python are audited in their own pull requests
and cited here. Findings land as their own pull request; the plan is
not complete until they are resolved or declined in writing.
Relationship to other plans¶
- shakenfist/private-ci
PLAN-placeable-claims. This plan is its phase 5. private-ci's phase 3 (per-node fit pre-check, in shadow) scoresC; its phase 6 (reserve bound cloud jobs before the runner boots) consumesA, behind capability checks, falling back to today's aggregate claim. private-ci's phase 4 wait in the collection'ssf_instance(30 minutes single-instance, 10 multi-instance, ontransient: trueonly; with #4474) is a separate change that is not part of this plan. Its phase 4a (PLAN-placeable-claims-phase-04a-regression.md) repaired the instrument the baseline above was taken with and referred one design constraint inward, which this plan's phase 0 absorbed asD15; itsF8is also half the evidence for phase 0'sD13. PLAN-scheduler-reservations(In progress).D14, its step 3 addendum and its "Node-scoped claims" future work are the decisions this plan revisits: aggregate claims stay, holds are added beside them. ItsD8/D19deferral of batch create stands —Bis not built. Its phase 5 (hard claim enforcement) decides what capping a claim's own limits means;G6binds other namespaces through the node guard, a different switch, so this plan states the interaction rather than waiting.Q17/D15(the operator's one-line path) andD17(unclaimed namespaces compete fortotal - claimed, unchanged by holds) carry forward.PLAN-transient-capacity-refusals(Complete). ItsOQ5costed a node-scoped claim; its first objection (a small window at concurrency 5) does not fit a window of minutes between a claim and a job's creates on a cluster that refused 93 creates in an hour, and its third (a claim-blind pre-filter becoming a ledger reader) is overtaken since the CPU and memory filters already read the node row. Its second objection (a ledger dimension, reconciler, claim migration, the guard) stands and isA's real cost. Its phase 5 answered open question 8 "no server-side queue", so waiting stays in callers and no hold is re-tried server-side.PLAN-per-host-resource-reservations(Complete). The per-host reservation overrides are the lever 33fl would use if phase 0's live-memory reading shows the host counted twice.
Out of scope¶
- Gang create (
B). APOSTof instances placed together or not at all. In the baseline window it would have changed at most 3 of 37 in-job refusals and found room for none; its all-or-nothing ends at the API because preflight re-places each member alone. scheduler-reservationsD8's deferral stands. Revisit only if private-ci's build waits record two multi-instance builds waiting into each other's holdings, or the multi-instance CI topologies cannot be held. - A server-side placement queue or re-placement wait. transient-capacity-refusals phase 5 decided against it; waiting stays in callers.
- Holds that promise live memory. The live checks stay; a hold promises the ledger, not a node's live idle memory.
- Hard claim enforcement (
CLAIM_ENFORCEMENT_HARD). That is scheduler-reservations phase 5. - CI concepts in the API (
G1). Topology, runner, job and label stay in the conductor.
Agent guidance¶
The execution model, planning-effort rule, step guidance, model
roster and management-session review checklist in PLAN-TEMPLATE.md
apply unchanged, including its project-specific checks (pre-commit
run --all-files for flake8, stestr unit tests and mypy; tox -e
genprotos if proto files change). The phase-by-phase effort
guidance is in the Execution section above: phases 0 to 5 are high
effort because they are the design or change the admission path;
phase 6 is medium where it mirrors existing patterns.
Each phase plan re-surveys its own citations against current
develop before writing steps, because the line numbers carried
into this plan from placeable-claims phase 0's brief are already
stale (see the Prompt).
Administration and logistics¶
Success criteria¶
- The
PLAN-TEMPLATE.mdsuccess criteria hold for every phase:pre-commit run --all-filespasses; new code follows the object lifecycle, three-layer MariaDB and Pydantic-schema patterns; filtering is pushed into SQL; there are unit tests and preferably functional coverage; proto changes are regenerated. - Both capabilities are advertised through capability strings, so an old client or server degrades to today's aggregate-claim behaviour.
Cis scored in shadow by private-ci, and its counts are read beforeA's admission changes are written.- A hold binds other namespaces through the node guard, independent
of
CLAIM_ENFORCEMENT_HARD. - Holds and displaced holds are inspectable per node in the claim's
view and
GET /admin/resources, andsummarize_resources()nets them out. - The primitive is documented in the operator and user guides, with
at least one non-conductor
G2caller using it or recorded as checked against it. - The change is reasoned about against a running cluster, not only unit tests.
Future work¶
- Lame-duck and evacuate (#1364) on the same per-node
mechanism, with an admin-scoped hold (
Q6). - The
G2holders adopt holds: 33fl'sstatic-runnersandstatic-ci, and the conductor'sci-imagesbuilder, are natural shape reservations onceAis proven. Adopting them is their owners' change. - Gang create (
B), if the multi-instance condition above arises. - The
ucs-lab-*namespaces' owner — placeable-claims phase 0D10found 10 post-change refusals in standingucs-lab-*namespaces and no record of who creates them; worth knowing before the design is called "every caller". - Affinity across a set's own members. Phase 1's
D6charges capacity across a multiset but leaves the hard affinity filter and the scorer blind to the set's tentatively placed members, because both read placedInstancerows and a shape has none. No known caller needs it; the eight-instancekerbside-patchestopology is the candidate if one does, and phase 2's shadow scoring is the instrument that would show it.
Bugs fixed during this work¶
- A
POST /instanceswith a JSON string vCPU or memory count returned 500, and still does. Phase 1 closed the scheduler's half of it and not the create. The API deliberately accepts{"cpus": "2"}--shakenfist/external_api/validation.pydeclines to refuse it on the grounds that the handler copes -- and the create path passes it toInstance.new()untouched, whereInstance.__init__stores static values as handed. The typed column means a later read gives an integer back, so the string survives exactly as long as the in-memory object a create's scheduling decision runs against, andinst.cpus > max_cpuraisedTypeErrorin thecpu_max_per_instancefilter. Phase 1's shape factory types those figures, because it has to in order to sort a set largest first, so the walk now completes. Admission then raises on the same string:Instance._capacity_claimreturnsself.cpusas handed andmariadb.admit_instance_placement()computescpus * config.SCHEDULER_DEMAND_PER_VCPU, a float, so'2' * 0.6raisesTypeErrorand the create still 500s -- now after the whole scheduling walk has run and published its audit events. Phase 1 deliberately did not cast there as well: a second cast in a third module would make the hole look closed whileInstance.cpusstayed a string for every other reader of it. The root cause is a request value reaching a handler untyped, which is PLAN-api-input-validation.md's subject -- but that plan isCompleteand never covered this site, so phase 2's survey filed the bug as shakenfist#4842 rather than leave it deferred to a closed plan. Two notes recorded there.validation.py's justification for accepting the string -- that pydantic's lax mode converts it -- is true of the database row and not of the object the scheduler and the admission path read. And the one-line fix is to type the value where the request becomes an instance (shakenfist/external_api/instance.py:1121), not at either reader: both of the casts a reader could add leave the next reader to find the next one.
None yet. Directly related open issues: shakenfist#1364
(lame-duck and evacuate), shakenfist#4474 (the functional
suite's wrapper waits on the exception class, ignoring transient).
An issue still needs filing, and phase 5 owns filing it: claim
refusals carry no machine-readable contract. shakenfist#4207 was
cited for this throughout an earlier draft of this plan, but it was
closed as completed on 2026-10-29 by the transient-capacity-refusals
plan, which gave the instance 507 its stage/transient/
Retry-After fields and did not touch the claim 507. Phase 0
re-checked the code: _claim_refusal() still returns a bare
sf_api.error() with the per-dimension detail folded into the prose
message. The gap is real and the requirement (G7, phase 5) stands;
only the issue that tracked it is gone. Phase 0 deliberately did not
file it — that is phase 5's first task, so the issue is written
against the design phase 5 actually ships.
The baseline and caller survey are shakenfist/private-ci
PLAN-placeable-claims phase 0.
Back brief¶
Before executing any step of this plan, please back brief the operator as to your understanding of the plan and how the work you intend to do aligns with that plan.