Skip to content

Delegated allocation: an agent splits the dollar budget between projects inside an operator envelope, with no keypress #504

Description

@edgehero

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-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

  1. 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.
    • New INT-ENVELOPE-FILE-CONTRACT and INT-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-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 write dispatch_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.
  2. 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.

    { "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 } }
  3. Protecting the envelope from model writes.

    • 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.
  4. 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):

    { "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 } ] }
    • Weights are integers from 0 to 1000.
    • 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).
  5. 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.
  6. Enforcement and money already reserved.

  7. 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.
  8. 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.

  9. 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.
    • The operator session. A model that reads issue text in the operator's own session breaks the scope clause of 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.
    • 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.
  10. 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.
  11. 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

What not to do

How to test it

Automated

  • 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.

  1. 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.
  2. 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.
  3. 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.
  4. Seed spend with no real money: valkey-cli INCRBY the 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 fake ANTHROPIC_API_KEY. Expect the refusal allocation-cap before any container starts. A platform job reaches the provider and gets the keyless 401, which proves it was admitted.
  5. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions