You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Delegated allocation: an agent splits the dollar budget between projects inside an operator envelope, with no keypress #504
The owner wants an agent to move dollar budget between projects on its own: more for the project that matters this week, less for the rest.
The operator sets the outer limits once: a total per window and a floor per project.
Today the doctrine forbids any model-made budget change without a human keypress, so this issue starts with a spec change.
The doctrine text that must change, verbatim with elisions marked:
specs/requirements.md:655-658, REQ-ADMIN-VIA-PI-EXTENSION Why: "The daily cap can be raised only with an operator's approval: a settings write (dailyCap included) is either operator-typed or a confirm-gated tool the model cannot self-approve [...] so a prompt-injected session cannot raise the cap without a human keypress it cannot forge."
specs/requirements.md:672-674, same entry, Acceptance: "given a model-invoked settings OR trigger write tool, when no interactive operator is present (ctx.hasUI false), then it refuses and writes nothing".
specs/requirements.md:795-797, REQ-SCOPED-LIMITS Why: "A cap is a bound, not a capability, which is why live editability is allowed here while run.image/run.packages/run.secrets stay file-only: a limit only ever narrows what may spend."
specs/design.md:2152-2170, DES-ADMIN-VIA-PI-EXTENSION, the "third residual" paragraph: "A third residual is named and bounded by a human confirm, not by structure [...] Strictly, tool absence was safer than a confirm [...] that trade is taken deliberately to make the surface AI-operable".
SECURITY.md:453-455: "What bounds them is not reversibility and not structure but a human keypress: each routes through one confirmedWrite funnel that refuses unless an interactive operator is present".
Note that the REQ-SCOPED-LIMITS sentence is already loose: dispatch_limit_edit can raise a cap today, behind a confirm. The amendment fixes that too.
Models reason poorly about a shared budget (R3-Bench, arXiv 2608.16033). So the agent writes priorities and pi-dispatch does the arithmetic.
What to build
Spec first, in the same PR.
New REQ-DELEGATED-ALLOCATION. The worker applies a priorities plan without a keypress, inside an operator envelope, under fixed rules, and records every attempt.
New DES-DELEGATED-ALLOCATION-INSIDE-ENVELOPE. It records the decision, the algorithm, the threat model and the rejected approaches listed below.
REQ-ADMIN-VIA-PI-EXTENSION Why. Keep the daily cap sentence and add: "One model-callable write needs no keypress: dispatch_priorities_set moves headroom between projects inside the operator's envelope. It cannot change the envelope, a floor, a cap, a trigger or a setting. A prompt-injected session can therefore shift spend between projects, bounded by the floors, the step and interval rules and the envelope total. It still cannot raise any ceiling without a human keypress it cannot forge."
REQ-ADMIN-VIA-PI-EXTENSION Statement. Add dispatch_allocations to the reads, dispatch_envelope_set to the confirm-gated writes, and a new kind, "the delegated allocation writedispatch_priorities_set". admin/test/wiring.test.mjs scans this list and the DES Decision list, so both must name all three tools in full.
REQ-ADMIN-VIA-PI-EXTENSION Acceptance. Change "a model-invoked settings OR trigger write tool" to "a model-invoked settings, trigger, limit or envelope write tool". Add: "given dispatch_priorities_set with or without an interactive operator, then it applies or refuses under REQ-DELEGATED-ALLOCATION and never shows a confirm".
REQ-SCOPED-LIMITS Why. Replace the sentence with: "A cap is a bound, not a capability, which is why live editability is allowed here while run.image/run.packages/run.secrets stay file-only. An operator edit may narrow or widen a limit, behind the operator's keypress. A delegated allocation may move headroom between projects without one, but never above the envelope total, never below a floor, and never above a limit the operator wrote."
DES-ADMIN-VIA-PI-EXTENSION. Add a "fourth residual, bounded by arithmetic rather than a keypress", with the worst-case numbers from part 9.
SECURITY.md. Keep the keypress bullet. Add a bullet after it: "One model-callable write needs no keypress, and it moves money between projects." It states the bound from part 9. Add a row to the trigger table: "Priorities plan (operator session tool or portfolio job) | whoever can prompt-inject either | revert in the panel". Add the envelope file to the trust boundaries table as operator trust.
CONST-BUDGET-BEFORE-TOKENS: UNCHANGED, checked. The ordering is untouched; only the values the dollar reserve compares against change.
The envelope file, PI_ENVELOPE_FILE (unset means no delegation anywhere). It is a sibling of scoped-limits.json. It has the same rules: version required, a newer version refused, refuse and never repair, atomic tmp and rename, a directory watch that keeps the last good file on a bad edit.
dispatch_priorities_set has no parameter that reaches it.
The only model-callable writer is dispatch_envelope_set, which goes through confirmedWrite (admin/src/index.ts:977) and is refused headless.
Job containers cannot reach it. The worker refuses to boot when the envelope path lies inside any cron run.folder, any PI_DISPATCH_RUN_ROOTS root, any run.skillsDir, or PI_GLOBAL_PI_DIR. Those are the host paths a container can see.
In the operator's session, the admin extension adds a pi.on("tool_call") guard. It blocks pi's built-in write and edit when the resolved path is the envelope file or projects.json. The pinned API supports this: ToolCallEventResult.block, dist/core/extensions/types.d.ts:1041-1044 at 0.99.1. The operator's own pi runs as the CLI, so it also loads pi's built-in codemode extension (0.99), whose scripts call tools through ctx.executeTool. Those nested calls run through the session's tool pipeline with its hooks (dist/core/nested-tool-calls.js:1-8), so the guard should see them; a test pins that a nested write to the envelope path is blocked.
bash cannot be filtered reliably, and neither can powershell, a built-in tool since 0.99.1 (registered but not active by default). That residual is named in SECURITY.md, beside the existing "same trust as shell access" bullet (SECURITY.md:625-630).
Detection covers the rest. The admin writer stores the digest it is about to write under alloc:envelope:expected. When the worker reloads a file with a different digest, it writes an envelope-changed-externally audit row, and the panel shows a banner.
The priorities plan (INT-PRIORITIES-PLAN-CONTRACT). It is a shared pure module, worker/src/priorities.mjs, used by the worker and the admin, with parsePlan(text):
The plan must name every project in the envelope. If not, it is refused as plan-incomplete.
repos is optional. When present, it must name every member by ref (the first 8 hex digits of sha256 of the canonical scope, so no path ever appears).
reason is optional and at most 200 characters. Control characters are refused, not stripped.
validUntil defaults to now plus maxPlanDays and may not exceed it.
basis is the plan id the writer saw, or null for the first plan.
Unknown keys are refused. This is deliberately stricter than INT-OUTBOX-CONTRACT, because this is a money file.
The plan id is the first 16 hex digits of sha256 over the canonical JSON (sorted keys).
The deterministic allocation, allocate({ envelope, weights, current }), pure, all BigInt micro-dollars:
R = total - sum(floors). share_p = floor(R * w_p / W), where W is the sum of weights.
The leftover micro-dollars go one each by largest fractional remainder, with ties broken by project id ascending. So the allocations sum exactly to the total.
With all weights 0, every project gets its floor and the rest stays unallocated. That is the money-safe direction.
Step rule. Let D be the largest change for any project and S = total * maxStepPct / 100. If D <= S, the target applies. Otherwise every project moves the same fraction S / D of the way, and the result is re-rounded by largest remainder. Unallocated money counts as one more entry. A blend of two valid vectors keeps every floor and the total, so clamping can never break an invariant. The record says clamped: true.
Per-repo shares split the project's allocation the same way, with no floors and no step.
allocate never returns more than the total, and a property test proves it.
A reservation already made is never taken back, and running jobs continue. A smaller allocation refuses new starts only.
When the envelope shrinks mid-window, the allocation is recomputed from the current weights at once, without the step rule, because it is the operator's own act. A project already over its new allocation is refused until the window rolls.
Where the current allocation lives: Valkey, because every host must enforce the same split.
alloc:plan holds the applied state: plan id, weights, reasons, writer, applied at, valid until, envelope digest, and micro-dollars per project and repo.
alloc:lock is a SET NX PX 5000, the idiom of worker/src/fleet-lease.mjs. Apply is read, compute, compare the sequence, write, under the lock. A busy lock refuses as plan-busy.
With alloc:plan missing (a flush), every host computes the neutral allocation from defaultWeights. That is deterministic and inside the envelope.
Each host publishes its envelope digest in its host:h:<name> row. A digest satisfies the content rule in worker/src/host-registry.mjs.
A host whose envelope digest differs from alloc:plan.envelopeDigest refuses envelope-governed jobs pre-spend as envelope-mismatch, and doctor names the hosts. Refusing is loud and money-safe. Silently judging one split against two envelopes is not.
Rules applied on every plan. Refusals return a fixed enum, checked in this order: delegation-off, writer-not-allowed, plan-duplicate (same id as current: a no-op, outcome duplicate), plan-stale (the basis is not the current id), plan-too-soon (less than minIntervalHours since the last applied plan), plan-incomplete, plan-busy.
Expiry: after validUntil, the worker applies the neutral allocation and writes an expired row. Turning delegation off applies neutral at once.
Threat model (goes into DES and SECURITY.md).
Prompt injection. Issue text read by a manager flow, or any text in the operator's session, can at worst move maxStepPct of the total per minIntervalHours. It cannot touch floors or the total. Worked example: $100 per week, floors of $10, a 25% step, a 24-hour interval. An attacker can push one project to $90 and the other to $10 in four days, and total spend stays at or under $100.
A compromised flow (a malicious skill in the manager folder) has the same bound. It also cannot write the envelope.
Reason text is agent-authored. It is shown in the panel only, through the control-byte gate. It never goes into a tool result, the run record or a log line. That follows the raw .log rule, REQ-ADMIN-VIA-PI-EXTENSION Why.
Audit log.
The file is the record: PI_LOGS_DIR/allocations/YYYY-MM.jsonl on the host that applied or refused. Rows hold: at, host, writer kind, job and trigger id, outcome, reason enum, plan id, basis, weights, micro-dollars before and after, clamped, envelope digest, reasons.
Retention is PI_LOG_RETENTION_DAYS, through a reaper added to worker/src/retention-sweep.mjs.
Valkey alloc:log is a view (LPUSH and LTRIM to 500), like worker/src/run-mirror.mjs: the file is written first.
Admin surface.
dispatch_allocations (read): envelope numbers, current allocations and spend, plan metadata and the last 20 outcomes. No reason text.
dispatch_priorities_set (sequential, no confirm, allowed headless): {projects:[{id, weight, reason?, repos?}], validDays?}. The tool fills in basis itself.
dispatch_envelope_set (confirm-gated): any subset of total, window, floors, default weights, delegation fields.
Operator-typed: /dispatch priorities shows the plan and /dispatch priorities set writes one. Both are zero-spend.
Panel: an ALLOCATION view on key p. It shows the envelope, the current plan with per-project reasons, and the history. r on a history row reverts to it after an in-frame y/n, recorded as operator-revert. A revert skips the interval and step rules, because it is an operator act.
With what
worker/src/budget.mjs window keys and the keyPrefix seam (scopeKeyPrefix, worker/src/scoped-limits.mjs:235).
The reserve-first, release-on-refusal order in worker/src/processor.mjs:859-911.
The file pattern of parseScopedLimits, readScopedLimits and writeScopedLimits (worker/src/scoped-limits.mjs:82, admin/src/read-model.mjs:679-728) and the watcher reloadScopedLimits (worker/src/start.mjs:249).
confirmedWrite and gateDialogs (admin/src/index.ts:977) for the envelope tool. The control-byte tests in admin/test/control-bytes.test.mjs for reasons.
The fleet lease idiom in worker/src/fleet-lease.mjs:53, host rows in worker/src/host-registry.mjs, and file-first mirroring in worker/src/run-mirror.mjs.
What not to do
Do not let the model write dollar numbers. Models misjudge shared budgets, and weights make every invariant checkable.
Do not put the envelope in Valkey. OQ-008 records why an operator edit must live in the file: a flush or a boot reconcile loses it.
Do not keep the current allocation in a per-host file. Two hosts would enforce two splits against one shared counter.
Do not claw back reservations or stop running jobs on a shrink. Stopping a paid run wastes what it already spent.
Do not add a confirm to dispatch_priorities_set. The owner decided on no keypress, and a confirm refused headless would make the unattended manager impossible.
Do not return reason text from any tool. It is container-authored text, and the raw log rule keeps such text out of model context.
worker/test/priorities.test.mjs: parsePlan refusals, one per field. Property tests on allocate: the sum equals the total when any weight is above 0, every floor holds, the step is never exceeded, the result is deterministic under a shuffled input order, and ties go by project id. Worked cases: $100, floors 10/10, weights 3:1 gives 70/30. Three projects with weight 1 and a $70M micro-dollar remainder gives 23,333,334 / 23,333,333 / 23,333,333 by id. From 50/50, weights 1:0 with a 25% step gives 75/25, clamped.
worker/test/envelope.test.mjs: every parse refusal, the newer-version refusal, the floors-over-total refusal, the row-below-floor refusal, and the boot refusal for a path inside a run root or cron folder.
worker/test/allocation-apply.test.mjs: the hand-rolled fake redis of worker/test/budget.test.mjs, extended with SET NX PX. Covers each refusal in order, the duplicate no-op, the stale basis, the busy lock, expiry to neutral, flush to neutral and envelope-mismatch. It takes an injected now, always. Include one case run under the 399-day Date shift in contract-tests.
worker/test/processor.test.mjs: allocation-cap refuses before runContainer and releases the narrower reservation like scope-cap. A shrink never touches a running job.
admin/test/crud.test.mjs: dispatch_priorities_set applies with ctx.hasUI false. dispatch_envelope_set refuses headless and writes nothing on decline. The tool_call guard blocks write and edit on the envelope path, including a nested call made through ctx.executeTool, and passes any other path.
admin/test/wiring.test.mjs goes green only once both spec lists name the three tools. dispatch_priorities_set is sequential.
Live Valkey (VALKEY_TEST_URL, required in CI): two apply calls racing produce one applied and one plan-stale or plan-busy.
Mutation checks, each of which must turn a test red: drop the largest-remainder pass, apply the step before the floors, compare the basis with !== on the wrong field, skip the digest compare.
By hand
All of this is zero-spend.
Set up projects.json with shop and platform, and an envelope of $100 per week with floors of 10 and 10. Run /dispatch priorities. Expect neutral 50/50, writer default.
Run /dispatch priorities set with shop 3 and platform 1. Expect applied, 70/30, not clamped. Repeat it at once. Expect plan-too-soon. Set minIntervalHours to 0 with dispatch_envelope_set and approve the confirm.
From a headless session, run pi -p "set priorities shop 1 platform 0". For no spend, use a local Ollama model in the operator's ~/.pi/agent/models.json with a nonzero cost table. Expect applied with clamped: true and 75/25, with no dialog.
Edit the envelope file with a text editor. Expect the panel banner "changed outside the panel" and an envelope-changed-externally row. Press r on the first history row. Expect operator-revert.
Acceptance
With $100 a week and floors of $10, weights 3:1 give $70 and $30 with no keypress, in a headless session.
A plan that is too soon, incomplete, stale, from a writer that is not allowed, or sent with delegation off is refused, recorded in the file and in alloc:log, and changes nothing.
No plan can move any project by more than maxStepPct of the total. Clamping is recorded.
A shrink refuses new starts with allocation-cap and leaves reservations and running jobs alone.
Two hosts enforce the same split. A host with a different envelope refuses with envelope-mismatch.
The panel shows the plan, the reasons and the history, and reverts. dispatch_allocations returns no reason text.
The spec amendments and the SECURITY.md text above are in the same PR, with revision rows. CONST-BUDGET-BEFORE-TOKENS is marked UNCHANGED, checked.
Depends on / unblocks
Depends on #499 (project ids and members) and #501 (micro-dollar windows, reserve then settle). Unblocks #505 (it reuses parsePlan and the apply path) and #506.
Open questions
Should _other exist, or should unassigned jobs be refused under an envelope? Recommendation: keep _other with floor 0 and default weight 1. Refusing would change behaviour for every trigger that is not in a project.
Should dispatch_priorities_set work headless? Recommendation: yes. The bound is arithmetic, not presence, and the refusal would buy nothing.
Should reasons live in Valkey? Recommendation: yes, in alloc:plan only, capped and printable, so every host's panel can say why. History reasons stay in the file.
Should the tool_call guard also cover scoped-limits.json, triggers.json and settings.json? Recommendation: yes, as a follow-up issue. The same gap exists there today.
Why this is needed
The owner wants an agent to move dollar budget between projects on its own: more for the project that matters this week, less for the rest.
The operator sets the outer limits once: a total per window and a floor per project.
Today the doctrine forbids any model-made budget change without a human keypress, so this issue starts with a spec change.
The doctrine text that must change, verbatim with elisions marked:
specs/requirements.md:655-658,REQ-ADMIN-VIA-PI-EXTENSIONWhy: "The daily cap can be raised only with an operator's approval: a settings write (dailyCap included) is either operator-typed or a confirm-gated tool the model cannot self-approve [...] so a prompt-injected session cannot raise the cap without a human keypress it cannot forge."specs/requirements.md:672-674, same entry, Acceptance: "given a model-invoked settings OR trigger write tool, when no interactive operator is present (ctx.hasUIfalse), then it refuses and writes nothing".specs/requirements.md:795-797,REQ-SCOPED-LIMITSWhy: "A cap is a bound, not a capability, which is why live editability is allowed here whilerun.image/run.packages/run.secretsstay file-only: a limit only ever narrows what may spend."specs/design.md:2152-2170,DES-ADMIN-VIA-PI-EXTENSION, the "third residual" paragraph: "A third residual is named and bounded by a human confirm, not by structure [...] Strictly, tool absence was safer than a confirm [...] that trade is taken deliberately to make the surface AI-operable".SECURITY.md:453-455: "What bounds them is not reversibility and not structure but a human keypress: each routes through oneconfirmedWritefunnel that refuses unless an interactive operator is present".Note that the
REQ-SCOPED-LIMITSsentence is already loose:dispatch_limit_editcan raise a cap today, behind a confirm. The amendment fixes that too.Models reason poorly about a shared budget (R3-Bench, arXiv 2608.16033). So the agent writes priorities and pi-dispatch does the arithmetic.
What to build
Spec first, in the same PR.
REQ-DELEGATED-ALLOCATION. The worker applies a priorities plan without a keypress, inside an operator envelope, under fixed rules, and records every attempt.DES-DELEGATED-ALLOCATION-INSIDE-ENVELOPE. It records the decision, the algorithm, the threat model and the rejected approaches listed below.INT-ENVELOPE-FILE-CONTRACTandINT-PRIORITIES-PLAN-CONTRACT. Unattended project manager: a portfolio snapshot into a flagged cron job, a priorities plan back out through the outbox #505 reuses the plan contract unchanged.REQ-ADMIN-VIA-PI-EXTENSIONWhy. Keep the daily cap sentence and add: "One model-callable write needs no keypress:dispatch_priorities_setmoves headroom between projects inside the operator's envelope. It cannot change the envelope, a floor, a cap, a trigger or a setting. A prompt-injected session can therefore shift spend between projects, bounded by the floors, the step and interval rules and the envelope total. It still cannot raise any ceiling without a human keypress it cannot forge."REQ-ADMIN-VIA-PI-EXTENSIONStatement. Adddispatch_allocationsto the reads,dispatch_envelope_setto the confirm-gated writes, and a new kind, "the delegated allocation writedispatch_priorities_set".admin/test/wiring.test.mjsscans this list and theDESDecision list, so both must name all three tools in full.REQ-ADMIN-VIA-PI-EXTENSIONAcceptance. Change "a model-invoked settings OR trigger write tool" to "a model-invoked settings, trigger, limit or envelope write tool". Add: "givendispatch_priorities_setwith or without an interactive operator, then it applies or refuses underREQ-DELEGATED-ALLOCATIONand never shows a confirm".REQ-SCOPED-LIMITSWhy. Replace the sentence with: "A cap is a bound, not a capability, which is why live editability is allowed here whilerun.image/run.packages/run.secretsstay file-only. An operator edit may narrow or widen a limit, behind the operator's keypress. A delegated allocation may move headroom between projects without one, but never above the envelope total, never below a floor, and never above a limit the operator wrote."DES-ADMIN-VIA-PI-EXTENSION. Add a "fourth residual, bounded by arithmetic rather than a keypress", with the worst-case numbers from part 9.SECURITY.md. Keep the keypress bullet. Add a bullet after it: "One model-callable write needs no keypress, and it moves money between projects." It states the bound from part 9. Add a row to the trigger table: "Priorities plan (operator session tool or portfolio job) | whoever can prompt-inject either | revert in the panel". Add the envelope file to the trust boundaries table as operator trust.CONST-BUDGET-BEFORE-TOKENS: UNCHANGED, checked. The ordering is untouched; only the values the dollar reserve compares against change.The envelope file,
PI_ENVELOPE_FILE(unset means no delegation anywhere). It is a sibling ofscoped-limits.json. It has the same rules:versionrequired, a newer version refused, refuse and never repair, atomic tmp and rename, a directory watch that keeps the last good file on a bad edit.{ "version": 1, "window": "week", "totalUsd": 100, "floorsUsd": { "shop": 10, "platform": 10, "_other": 0 }, "defaultWeights": { "shop": 1, "platform": 1, "_other": 1 }, "delegation": { "enabled": true, "writers": ["operator-session", "portfolio-job"], "maxStepPct": 25, "minIntervalHours": 24, "maxPlanDays": 14 } }windowis one ofday,week,month. It uses the same UTC buckets asworker/src/budget.mjs(dayKey,weekKeyMonday start,monthKey).floorsUsdmust be a project id fromprojects.json(Projects: group repos and folders into a project that is recorded per run, folded in the cost views and capped as one #499), or_other._othermeans every scope that is in no project. The sum of the floors must not exceed the total.Protecting the envelope from model writes.
dispatch_priorities_sethas no parameter that reaches it.dispatch_envelope_set, which goes throughconfirmedWrite(admin/src/index.ts:977) and is refused headless.run.folder, anyPI_DISPATCH_RUN_ROOTSroot, anyrun.skillsDir, orPI_GLOBAL_PI_DIR. Those are the host paths a container can see.pi.on("tool_call")guard. It blocks pi's built-inwriteandeditwhen the resolved path is the envelope file orprojects.json. The pinned API supports this:ToolCallEventResult.block,dist/core/extensions/types.d.ts:1041-1044at 0.99.1. The operator's own pi runs as the CLI, so it also loads pi's built-in codemode extension (0.99), whose scripts call tools throughctx.executeTool. Those nested calls run through the session's tool pipeline with its hooks (dist/core/nested-tool-calls.js:1-8), so the guard should see them; a test pins that a nestedwriteto the envelope path is blocked.bashcannot be filtered reliably, and neither canpowershell, a built-in tool since 0.99.1 (registered but not active by default). That residual is named inSECURITY.md, beside the existing "same trust as shell access" bullet (SECURITY.md:625-630).alloc:envelope:expected. When the worker reloads a file with a different digest, it writes anenvelope-changed-externallyaudit row, and the panel shows a banner.The priorities plan (
INT-PRIORITIES-PLAN-CONTRACT). It is a shared pure module,worker/src/priorities.mjs, used by the worker and the admin, withparsePlan(text):{ "version": 1, "basis": "3f9a0c1d2e4b5a67", "validUntil": "2026-10-12T00:00:00Z", "projects": [ { "id": "shop", "weight": 3, "reason": "launch on Friday", "repos": [ { "ref": "a1b2c3d4", "weight": 2 }, { "ref": "9e8d7c6b", "weight": 1 } ] }, { "id": "platform", "weight": 1, "reason": "maintenance only" }, { "id": "_other", "weight": 0 } ] }plan-incomplete.reposis optional. When present, it must name every member byref(the first 8 hex digits of sha256 of the canonical scope, so no path ever appears).reasonis optional and at most 200 characters. Control characters are refused, not stripped.validUntildefaults to now plusmaxPlanDaysand may not exceed it.basisis the plan id the writer saw, ornullfor the first plan.INT-OUTBOX-CONTRACT, because this is a money file.The deterministic allocation,
allocate({ envelope, weights, current }), pure, all BigInt micro-dollars:R = total - sum(floors).share_p = floor(R * w_p / W), whereWis the sum of weights.Dbe the largest change for any project andS = total * maxStepPct / 100. IfD <= S, the target applies. Otherwise every project moves the same fractionS / Dof the way, and the result is re-rounded by largest remainder. Unallocated money counts as one more entry. A blend of two valid vectors keeps every floor and the total, so clamping can never break an invariant. The record saysclamped: true.allocatenever returns more than the total, and a property test proves it.Enforcement and money already reserved.
min(operator row, allocation), and a repo's becomesmin(operator row, repo share).min(operator cap, envelope total). That keeps_otherand project spend under one total.allocation-cap, a new policy token, pre-spend, never retried. It stays distinct from Dollar budgets: a per-job cost cap enforced before each provider call, and dollar windows reserved before the run and settled after #501'sdollar-cap, so the operator can tell "the agent's split" from "my cap".boundExceeded), stated once.Where the current allocation lives: Valkey, because every host must enforce the same split.
alloc:planholds the applied state: plan id, weights, reasons, writer, applied at, valid until, envelope digest, and micro-dollars per project and repo.alloc:lockis aSET NX PX 5000, the idiom ofworker/src/fleet-lease.mjs. Apply is read, compute, compare the sequence, write, under the lock. A busy lock refuses asplan-busy.alloc:planmissing (a flush), every host computes the neutral allocation fromdefaultWeights. That is deterministic and inside the envelope.host:h:<name>row. A digest satisfies the content rule inworker/src/host-registry.mjs.alloc:plan.envelopeDigestrefuses envelope-governed jobs pre-spend asenvelope-mismatch, and doctor names the hosts. Refusing is loud and money-safe. Silently judging one split against two envelopes is not.Rules applied on every plan. Refusals return a fixed enum, checked in this order:
delegation-off,writer-not-allowed,plan-duplicate(same id as current: a no-op, outcomeduplicate),plan-stale(the basis is not the current id),plan-too-soon(less thanminIntervalHourssince the last applied plan),plan-incomplete,plan-busy.Expiry: after
validUntil, the worker applies the neutral allocation and writes anexpiredrow. Turning delegation off applies neutral at once.Threat model (goes into
DESandSECURITY.md).maxStepPctof the total perminIntervalHours. It cannot touch floors or the total. Worked example: $100 per week, floors of $10, a 25% step, a 24-hour interval. An attacker can push one project to $90 and the other to $10 in four days, and total spend stays at or under $100.CONST-ISOLATION-CONTAINER-PER-JOB(specs/constitution.md:64-68, "processes no adversarial input"). The docs say the judgement belongs in a job (Unattended project manager: a portfolio snapshot into a flagged cron job, a priorities plan back out through the outbox #505), and the tool is for the operator's own instructions..logrule,REQ-ADMIN-VIA-PI-EXTENSIONWhy.Audit log.
PI_LOGS_DIR/allocations/YYYY-MM.jsonlon the host that applied or refused. Rows hold: at, host, writer kind, job and trigger id, outcome, reason enum, plan id, basis, weights, micro-dollars before and after, clamped, envelope digest, reasons.PI_LOG_RETENTION_DAYS, through a reaper added toworker/src/retention-sweep.mjs.alloc:logis a view (LPUSH and LTRIM to 500), likeworker/src/run-mirror.mjs: the file is written first.Admin surface.
dispatch_allocations(read): envelope numbers, current allocations and spend, plan metadata and the last 20 outcomes. No reason text.dispatch_priorities_set(sequential, no confirm, allowed headless):{projects:[{id, weight, reason?, repos?}], validDays?}. The tool fills inbasisitself.dispatch_envelope_set(confirm-gated): any subset of total, window, floors, default weights, delegation fields./dispatch prioritiesshows the plan and/dispatch priorities setwrites one. Both are zero-spend.p. It shows the envelope, the current plan with per-project reasons, and the history.ron a history row reverts to it after an in-frame y/n, recorded asoperator-revert. A revert skips the interval and step rules, because it is an operator act.With what
worker/src/budget.mjswindow keys and thekeyPrefixseam (scopeKeyPrefix,worker/src/scoped-limits.mjs:235).worker/src/processor.mjs:859-911.projects.jsonand project ids.parseScopedLimits,readScopedLimitsandwriteScopedLimits(worker/src/scoped-limits.mjs:82,admin/src/read-model.mjs:679-728) and the watcherreloadScopedLimits(worker/src/start.mjs:249).confirmedWriteandgateDialogs(admin/src/index.ts:977) for the envelope tool. The control-byte tests inadmin/test/control-bytes.test.mjsfor reasons.worker/src/fleet-lease.mjs:53, host rows inworker/src/host-registry.mjs, and file-first mirroring inworker/src/run-mirror.mjs.What not to do
OQ-008records why an operator edit must live in the file: a flush or a boot reconcile loses it.dispatch_priorities_set. The owner decided on no keypress, and a confirm refused headless would make the unattended manager impossible.How to test it
Automated
worker/test/priorities.test.mjs:parsePlanrefusals, one per field. Property tests onallocate: the sum equals the total when any weight is above 0, every floor holds, the step is never exceeded, the result is deterministic under a shuffled input order, and ties go by project id. Worked cases: $100, floors 10/10, weights 3:1 gives 70/30. Three projects with weight 1 and a $70M micro-dollar remainder gives 23,333,334 / 23,333,333 / 23,333,333 by id. From 50/50, weights 1:0 with a 25% step gives 75/25, clamped.worker/test/envelope.test.mjs: every parse refusal, the newer-version refusal, the floors-over-total refusal, the row-below-floor refusal, and the boot refusal for a path inside a run root or cron folder.worker/test/allocation-apply.test.mjs: the hand-rolled fake redis ofworker/test/budget.test.mjs, extended with SET NX PX. Covers each refusal in order, the duplicate no-op, the stale basis, the busy lock, expiry to neutral, flush to neutral andenvelope-mismatch. It takes an injectednow, always. Include one case run under the 399-dayDateshift incontract-tests.worker/test/processor.test.mjs:allocation-caprefuses beforerunContainerand releases the narrower reservation likescope-cap. A shrink never touches a running job.admin/test/crud.test.mjs:dispatch_priorities_setapplies withctx.hasUIfalse.dispatch_envelope_setrefuses headless and writes nothing on decline. Thetool_callguard blockswriteandediton the envelope path, including a nested call made throughctx.executeTool, and passes any other path.admin/test/wiring.test.mjsgoes green only once both spec lists name the three tools.dispatch_priorities_setissequential.VALKEY_TEST_URL, required in CI): two apply calls racing produce oneappliedand oneplan-staleorplan-busy.!==on the wrong field, skip the digest compare.By hand
All of this is zero-spend.
projects.jsonwithshopandplatform, and an envelope of $100 per week with floors of 10 and 10. Run/dispatch priorities. Expect neutral 50/50, writerdefault./dispatch priorities setwith shop 3 and platform 1. Expectapplied, 70/30, not clamped. Repeat it at once. Expectplan-too-soon. SetminIntervalHoursto 0 withdispatch_envelope_setand approve the confirm.pi -p "set priorities shop 1 platform 0". For no spend, use a local Ollama model in the operator's~/.pi/agent/models.jsonwith a nonzerocosttable. Expectappliedwithclamped: trueand 75/25, with no dialog.valkey-cli INCRBYthe shop project's week key from Dollar budgets: a per-job cost cap enforced before each provider call, and dollar windows reserved before the run and settled after #501 by 76000000. The panel key view prints the key name. Then run a shop job with a syntactically valid fakeANTHROPIC_API_KEY. Expect the refusalallocation-capbefore any container starts. A platform job reaches the provider and gets the keyless 401, which proves it was admitted.envelope-changed-externallyrow. Pressron the first history row. Expectoperator-revert.Acceptance
alloc:log, and changes nothing.maxStepPctof the total. Clamping is recorded.allocation-capand leaves reservations and running jobs alone.envelope-mismatch.dispatch_allocationsreturns no reason text.SECURITY.mdtext above are in the same PR, with revision rows.CONST-BUDGET-BEFORE-TOKENSis marked UNCHANGED, checked.Depends on / unblocks
Depends on #499 (project ids and members) and #501 (micro-dollar windows, reserve then settle). Unblocks #505 (it reuses
parsePlanand the apply path) and #506.Open questions
_otherexist, or should unassigned jobs be refused under an envelope? Recommendation: keep_otherwith floor 0 and default weight 1. Refusing would change behaviour for every trigger that is not in a project.dispatch_priorities_setwork headless? Recommendation: yes. The bound is arithmetic, not presence, and the refusal would buy nothing.alloc:planonly, capped and printable, so every host's panel can say why. History reasons stay in the file.tool_callguard also coverscoped-limits.json,triggers.jsonandsettings.json? Recommendation: yes, as a follow-up issue. The same gap exists there today.