Decisions and their rationale. Non-negotiables live in constitution.md; what the system must do lives
in requirements.md. Each entry records what was chosen, why, and what was rejected — so the
question does not come back.
Evidence convention as in constitution.md: Evidence (upstream) is authoritative, Reference is not.
GitHub repo(s)
│ webhooks: issues [opened, labeled], issue_comment [created] (HMAC-signed)
▼ ── PUBLIC EDGE ──────────────────────────────────────────────
┌──────────────────────────────┐
│ receiver (always-on, tiny) │ verify signature → filter (label allowlist,
│ Node + Express │ trusted-sender check) → enqueue job
│ binds 0.0.0.0 — MUST be │ NO dashboard, NO admin surface here.
│ internet-reachable │
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ Valkey + BullMQ "pi-jobs" │◀──────▶│ operator terminal (on host) │
│ THE WAIT-LIST: 50 triggers │ │ pi + admin extension │
│ = 50 pending jobs, drained │ │ /dispatch pause|resume, │
│ at fixed concurrency; dedup │ │ status runs logs budget, │
│ by delivery GUID; retries; │ │ settings via TUI overlay │
│ daily budget cap │ │ ── no network bind at all │
└──────────────┬───────────────┘ └──────────────┬───────────────┘
▼ one job per worker slot │ reads/writes
┌──────────────────────────────┐ ┌──────────────▼───────────────┐
│ worker (BullMQ Worker proc) │───────▶│ data volume │
│ fresh clone → docker run │ reads │ settings.json + flows/*.md │
└──────────────┬───────────────┘ └──────────────────────────────┘
▼
┌─────────────────────────────────────────────┐
│ pi-job container (ephemeral, per job) │
│ • pi (SDK runner) + git + gh │
│ • Playwright + headless Chromium │
│ • /job:ro = flow + issue payload │
│ • persona BAKED INTO THE IMAGE │
│ (hard rules; unreachable from the │
│ admin surface or from /job — see │
│ INT-CONTAINER-JOB-INPUTS) │
│ edit → screenshot → iterate → commit → │
│ push branch → gh pr create → issue comment │
└─────────────────────────────────────────────┘
Everything above the container is a few hundred lines of TypeScript. Everything below is pi.
Architectural style: a queue-worker pipeline with a hard isolation boundary. Explicitly not
MACH — that model scores 0/4 here and adopting its vocabulary would mislead every future reader.
Not microservices: three processes on one box is a pipeline, and splitting a few hundred lines into
independently-deployable services would be parody. Not API-first: there is no public API; the only
inbound contract is a webhook whose shape GitHub owns. Not cloud-native: actively rejected —
systemd on owned hardware, hosted runners declined (see DES-BUILD-NOT-EXTEND-PI-ROUTINES and the
rejected alternatives below). Headless only vacuously, which is not a commitment.
Everything follows from two constraints, both of which exist because pi provides neither: the agent is
unrestricted against adversarial input (CONST-ISOLATION-CONTAINER-PER-JOB), and every job spends real
money with no upstream turn limit (REQ-RUNNER-TURN-BUDGET).
- Decision: Keep the name
pi-dispatch. Do not publish under the bare npm name — it is taken. Scoped@edgehero/*publishing is the sanctioned channel (amended 2026-08-02, issue #80; the original Decision line read "Do not publish to npm" unqualified, and practice had already diverged:@edgehero/pi-dispatch-adminshipped 2026-07 without this entry recording it). Published artifacts:@edgehero/pi-dispatch-admin(the console),@edgehero/pi-dispatch(the worker + CLI — the "management CLI" this entry's change trigger named), and@edgehero/pi-dispatch-receiver. The bin name stayspi-dispatch, which shadows nothing locally; the README warns that barenpx pi-dispatchoutside a checkout resolves to the unrelated squatted package, so docs always use the scoped form. - Why:
pi-dispatchis taken on npm —pi-dispatch@1.0.3, a pi extension that rotates ChatGPT Codex OAuth accounts to maximise quota. It does not bind us: it is functionally unrelated (it runs inside a pi session; this project runs pi inside itself), it was published once on 2026-04-06 with no release since, and its GitHub repository returns 404. We do not need the npm name — the bare name, that is: scoped@edgehero/*names have no collision at all, which is what made the 2026-08-02 amendment a scoping of this entry rather than a reversal. GitHub namespaces by owner, so there is no conflict there either. Recorded because the collision is real and the question will otherwise return every time someone searches npm. - What would change this: wanting to publish any npm artifact under this name — a management CLI,
a client library. At that point rename;
pi-foremanandpi-ondutywere verified available. The trigger fired (issue #80: the worker CLI is exactly "a management CLI") and the resolution chosen was scoped publishing, not the rename: the collision only ever bound the bare name, the@edgeheroscope was already shipping the admin console, and renaming a documented project to route around a squatter's dead package is cost without benefit. A bare-name artifact would still require the rename this entry prescribes. - Evidence (upstream):
registry.npmjs.org/pi-dispatch— versions 1.0.2 and 1.0.3 both published 2026-04-06;time.modified2026-07-06 is a metadata touch, not a release;repository.url→github.com/vincenthopf/pi-dispatch→ HTTP 404 - Traces to:
README.md(disambiguation note)
- Decision: The trigger layer is a separate always-on process. It is not a pi extension.
- Why: pi's event system observes a running session —
session_start,before_agent_start,tool_call, and so on. There are no cron, webhook, or external-trigger event types. An extension can drive turns programmatically (pi.sendUserMessage()always triggers a turn), so a webhook listener inside an extension is technically possible — but the triggers would die with the session, which contradicts the always-on goal outright and reproduces the exact structural flaw that made the closest existing tool unusable ("always-on / laptop closed: no"). This decision is why the repository exists at all; without it there is nothing to build. - Every forge arm is conditional, including GitHub (amended, issue #99). The receiver mounts a
forge's route, resolves that forge's own identity for the bot-loop guard, and requires that forge's
credentials only when the deployment serves it. GitHub was the exception: its identity resolution
and
WEBHOOK_SECRETwere unconditional while the other three arms were already gated, so a GitLab-only, Forgejo-only or Azure-only deployment could not boot withoutghlogged in and a webhook secret it would never use. The gate is now uniform, and the coupling is the safety property: skipping identity resolution is sound only because the route is absent too. An unconfigured forge answers 404, not 401 — an endpoint that answers is an endpoint an operator can believe is armed — and if/is ever mounted unconditionally again, the guard must return with it. - Evidence (upstream):
earendil-works/pi @ 5e336cf → packages/coding-agent/docs/extensions.md(event type union; no external-trigger types) - Rejected: webhook listener inside a pi extension — session-bound lifetime.
- Traces to:
REQ-QUEUE-BURST-NO-DROP,CONST-HMAC-OVER-RAW-BODY
- Decision: Redis + BullMQ.
- Why: BullMQ supplies priorities, a rate limiter, a dedup window, and stalled-job recovery with a
retry policy — each of which is an independent requirement here, not a bonus. Building four mechanisms
to avoid one dependency is precisely how a solo-maintainer project drowns in maintenance. Its Bull
Board dashboard was a fifth draw;
DES-ADMIN-VIA-PI-EXTENSIONdrops the served web surface entirely (the insights HTML artifact is a static file the operator's own browser opens — nothing serves it, nothing listens), so the case now stands on the four queue mechanisms alone. Redis persistence (AOF) is what makesREQ-QUEUE-BURST-NO-DROPsurvive a reboot; an in-memory queue would lose the wait-list on the first restart. - Evidence (upstream): BullMQ is MIT (
taskforcesh/bullmq → LICENSE, © BullForce Labs AB) - Rejected:
- Hand-rolled Redis list — reimplements the five mechanisms above, badly, forever.
- GitHub Actions
concurrency:groups — claude-code-action's own docs concede the action has no queue either; concurrency groups cancel or serialise, they do not hold a wait-list. - The existing tool's depth-3 FIFO — see
DES-BUILD-NOT-EXTEND-PI-ROUTINES.
- Deployment note: default the compose file to Valkey (BSD-3, Linux Foundation) rather than Redis. Redis ≥8.0 is tri-licensed AGPLv3 / SSPLv1 / RSALv2; the AGPL does not reach this project — we speak RESP over a socket and do not link Redis — but Valkey means the conversation never happens for downstream self-hosters either. Valkey status — good enough to proceed, not proven. BullMQ's own marketing page lists Valkey among supported backends, but its compatibility documentation commits only to "full Redis™ compliant with version 6.2.0 or newer… not all the alternatives are going to work properly". The widely-repeated claim that BullMQ's test suite runs against Valkey is UNVERIFIED — it traces to secondary sources, not to BullMQ, and must not be cited as fact. What is solid: BullMQ talks RESP through ioredis with no Redis-specific handshake, and the only Valkey-related upstream issues are feature requests for a Valkey Glide adapter — not bug reports about basic compatibility. No Lua incompatibility reports exist, which matters because BullMQ is Lua-heavy. Proceed on Valkey; keep Redis 8 as the documented fallback and treat any queue weirdness as a Valkey suspect first.
- Consequences worth knowing before sizing anything — all source- or doc-verified:
- The rate limiter is global, not per worker. "The rate limiter is global, so if you have for
example 10 workers for one queue with the above settings, still only 10 jobs will be processed by
second." Do not multiply
limiter.maxby worker count.concurrencyis the opposite — it is per-Worker-instance. Two adjacent options with opposite scoping is a trap worth writing down. queue.pause()is durable and global, implemented as a Redis-side rename of thewaitkey topaused— so it survives a restart, which is what makes it usable as the admin extension's on/off switch (/dispatch pause//dispatch resume, stillqueue.pause()/queue.resume(), still durable). New jobs are still accepted while paused (they land inpaused); in-flight jobs run to completion. That is the correct semantics forDES-ADMIN-VIA-PI-EXTENSION's switch: off means "stop starting work", not "start dropping work" — dropping would violateREQ-QUEUE-BURST-NO-DROP.- Stalled-job recovery re-runs paid jobs by default — see
CONST-RETRY-INFRA-ONLY.
- The rate limiter is global, not per worker. "The rate limiter is global, so if you have for
example 10 workers for one queue with the above settings, still only 10 jobs will be processed by
second." Do not multiply
- Reference (no authority):
docs.bullmq.io/guide/rate-limiting,/guide/workers/pausing-queues,/guide/redis-tm-compatibility.
- Decision: The per-job run history is a flat
node:fssidecar keyed by job id — an id-only status record atlogs/<jobId>.json(written withfs.writeFileSync) plus an optional append-onlylogs/<jobId>.logof raw container output (fs.createWriteStream). No database, no logging framework. Retention is a boot-time age sweep (makeLogReaper, windowPI_LOG_RETENTION_DAYS), not a rotation library. - Why: The record must outlive the queue entry. BullMQ evicts completed and failed jobs by age
(
removeOnComplete/removeOnFail), so the retained job cannot be the durable store — and it never carriesexitCode,turns, orbudgetReservedin the first place. The sidecar is justified precisely by what BullMQ lacks: a record that survives eviction and holds the run's outcome fields. Those records are immutable, filename-keyed, and never queried across each other, so a store with query power earns nothing. This isDES-QUEUE-BULLMQ-OVER-CUSTOM's library-first ethos one file down, and the design home for thedocument-build-decisionrule that the sidecar's inlineCustom:comment cites. - Rejected:
- Querying BullMQ's retained job instead of a sidecar — BullMQ evicts by age, so it is not the
durable store, and it never carries
exitCode/turns/budgetReserved. It cannot be the record. - An embedded database (lowdb / better-sqlite3) or a structured-logging library (pino / winston) —
the records are immutable, filename-keyed, with no cross-record query in scope, so neither earns its
keep. A DB adds a native build (the ARM / musl / glibc pain the job image exists to avoid) and a
second retention authority beside the reaper for zero query benefit; a logging library brings its own
rotation as that same second authority. Both violate the deliberate "no database" thinness
(
interfaces.mdpreamble) and the library-first ethos — the same reasoning asDES-QUEUE-BULLMQ-OVER-CUSTOM.
- Querying BullMQ's retained job instead of a sidecar — BullMQ evicts by age, so it is not the
durable store, and it never carries
- Traces to:
DES-QUEUE-BULLMQ-OVER-CUSTOM; implemented inworker/src/run-history.mjs.
- Decision: Bake the persona into the image at
~/.pi/agent/APPEND_SYSTEM.md, and pass per-flow additions viaappendSystemPromptOverride— never via bareappendSystemPrompt. - Why: The obvious reading of these two mechanisms is that they compose. They do not. Passing
appendSystemPromptreplaces file discovery — the??means the bakedAPPEND_SYSTEM.mdis never looked for. The persona vanishes with no error, no warning, and a job that completes successfully.appendSystemPromptOverridereceives the discovered content asbase, so(base) => [...base, perFlowText]preserves both. This is the single most dangerous trap in the integration and is whyREQ-UPSTREAM-CONTRACT-TESTSasserts both strings reach the assembled prompt: no other mechanism would ever tell us. The global path is chosen over the project path because~/.pi/agent/has no trust gate, while project.pi/*resources are gated byisProjectTrusted()and headless modes ignore them absent saved trust — nondeterministic inside a container is unacceptable for the file that carries our standing rules. Requires coding-agent ≥ v0.46.0; confirmed present at the pin. - Evidence (upstream):
earendil-works/pi @ 5e336cf → resource-loader.ts:480-482—const appendSources = this.appendSystemPromptSource ?? (this.discoverAppendSystemPromptFile() ? [...] : [])·→ resource-loader.ts:156 → appendSystemPromptOverride?: (base: string[]) => string[]·→ resource-loader.ts:979-991 → discoverAppendSystemPromptFile(global path ungated) ·→ CHANGELOG.md [0.46.0] - 2026-01-15("SupportAPPEND_SYSTEM.md…") - Rejected:
SYSTEM.md— replaces pi's default prompt entirely, losing its built-in tool guidance. We want to add to pi's behaviour, not supplant it.- Project-level
.pi/APPEND_SYSTEM.md— trust-gated; headless ignores it without--approve. AGENTS.md— not the persona channel, though it does now load (CONST-NO-CONTEXT-FILES-MANDATORY, amended:/workspaceis the base repo's default-branch sha, so it is merge-gated). It is rejected here for placement, not trust: pi emits context files into<project_context>after the append block, so a repo'sAGENTS.mdcarries its conventions and can never be the floor. That ordering is what lets both ship.- Extension returning
systemPromptfrombefore_agent_start— genuinely works and is cache-friendly when deterministic (the original pi-caveman demonstrates this). Rejected for moving parts: load-order chaining, per-prompt re-return, extension loading in headless mode. Reserve for prompt logic that cannot be expressed as a file. - Per-message injection — see
CONST-PERSONA-IN-CACHED-PREFIX.
- Traces to:
CONST-PERSONA-IN-CACHED-PREFIX,INT-SDK-SESSION-OPTIONS,REQ-UPSTREAM-CONTRACT-TESTS
- Decision: Build fresh rather than extend the existing
pi-routinescommunity project. - Why: Adding the missing GitHub event types to its poller would be a small PR — but the blockers
are structural, not featural: execution is session-bound (routines run as turns inside a live
interactive session, single-flighted), the overflow queue is depth 3, and the trigger server hard-binds
to
127.0.0.1with no config. Fixing those means replacing the core while inheriting the name and its users' expectations. Two of its ideas were adopted instead of its code: budget-before-tokens (CONST-BUDGET-BEFORE-TOKENS) and fire dedup (REQ-DEDUP-BY-DELIVERY-GUID). - Evidence (upstream):
Davidcreador/pi-routines @ 6d2aa64 (v0.5.1) → src/types.ts:105(event union:pull_request.opened|closed,issues.opened,push— noissues.labeled, noissue_comment) ·→ src/types.ts:423 → MAX_QUEUE_DEPTH = 3·→ src/guard.ts → isRoutineTurnActive·→ src/server.ts(hard127.0.0.1:7424bind)
- Decision: Default worker concurrency 3, exposed as
PI_CONCURRENCY. - Why: Two soft limits, and 3 is their conservative intersection. RAM (~1.5–2.5 GB/job against a
16 GB box) suggests 3 — but RAM is probably not the binding constraint: the provider's tier
throttles concurrent streams and tokens-per-minute long before Docker runs out of memory. A config
knob rather than a constant because one of the two inputs is an unmeasured guess (
OQ-002), so re-tuning after measurement should be a deploy, not a code change. - Boot-reaper invariant — single worker per docker daemon: The knob is parallelism within one worker
process; the design assumes exactly one worker per docker daemon. The boot reaper clears every stray
pi-job-*container on start (a leaked container keeps spending, so it must go before any new job launches) — a co-located second worker sharing the daemon would read the first's in-flight container as its own to remove and kill a live job. Per-host is the common case, but the docker daemon, not the host, is the true boundary. Since issue #80 the invariant is enforced where units are minted:pi-dispatch service installrefuses to install a worker unit when one exists in the other scope (user vs system LaunchAgent/ LaunchDaemon,systemctl --uservs/etc/systemd/system) — before this, the paragraph above was the only thing standing between an operator and two workers sharing one daemon. - Traces to:
OQ-002,REQ-QUEUE-BURST-NO-DROP
- Decision: Scheduled triggers use BullMQ Job Schedulers (
upsertJobScheduler). We do not build a cron, and we do not use the deprecatedrepeat:API. A schedule is a trigger, not a job kind — it produces an ordinary job aimed at either a GitHub repo or a local folder. - Why: pi has no cron (
DES-TRIGGER-OUTSIDE-PI), and hand-rolling one means reimplementing cron parsing, persistence, missed-tick policy and overlap control — four mechanisms, the exact drowningDES-QUEUE-BULLMQ-OVER-CUSTOMrefused. BullMQ's scheduler is a Redis object, not a JS timer (ZADD repeat <nextMillis> <id>+HMSET), so it survives a worker restart with nothing to lose and a Redis restart under the AOF we already require. Three of its properties are exactly what a money-spending harness needs, and all three are verified rather than assumed:- No backfill. Six hours down with an hourly schedule costs one paid run on restart, not six —
the
everypath aligns forward to a single next slot; thepatternpath asks cron-parser for onenext. Neither loops. This is the difference between a reboot and a bill. - No overlap, structurally. The next job is only created when the current one starts processing,
so a 30-minute flow on a 10-minute schedule yields one job every 30 minutes rather than three
concurrent agent runs. The cost is silent under-firing — actual cadence degrades below the configured
one under load, so the admin extension should surface
nextdrift rather than let it look healthy. - Deterministic
jobId—repeat:<schedulerId>:<nextMillis>— so scheduler jobs getREQ-DEDUP-BY-DELIVERY-GUID-equivalent dedup for free, with no GUID to supply. - Local-only this slice. The on × run diagonal rejects a
cron → githubtrigger at load (INT-TRIGGERS-FILE-CONTRACT,DES-TRIGGERS-UNIFIED-FILE). A scheduled job on any forge has no webhook delivery, issue/PR number, title, or body to supply, and post-integration the github path would perform a real host clone and per-job token mint before failing every tick — spend and side effects for a trigger that cannot complete. GitHub scheduling is deferred to a later slice. - Two distinct enqueue paths, not duplication. The interactive
enqueueLocalJobsetsattempts: 2with backoff; the scheduled path (upsertJobScheduler) passes retention-only opts with a single attempt. For an unattended recurring trigger the cadence is the retry, so a failing tick must not multiply spend within one tick — two distinct triggers with different retry semantics, not two implementations of one thing. Legacyrepeat:is deprecated and slated for removal in v6 — starting on it would be adopting a known-dead API.
- No backfill. Six hours down with an hourly schedule costs one paid run on restart, not six —
the
- Evidence (upstream):
taskforcesh/bullmq @ v5.80.4 → src/classes/queue.ts:468-495 → upsertJobScheduler·→ queue.ts:651—@deprecated … will be removed in v6. Use removeJobScheduler instead·→ src/commands/includes/getJobSchedulerEveryNextMillis.lua—nextMillis = prevMillis + everythen, verbatim,-- check if we may have missed some iterations, resolving to a single aligned slot ·→ src/commands/addJobScheduler-11.lua:144—local jobId = "repeat:" .. jobSchedulerId .. ":" .. nextMillis·→ addJobScheduler-11.lua:164,191— returns-11SchedulerJobSlotsBusy/-10SchedulerJobIdCollision·→ src/commands/includes/storeJobScheduler.lua(Redis-resident schedule) ·→ src/classes/queue.ts:603-696—getJobScheduler/getJobSchedulers(start,end,asc)/getJobSchedulersCount/removeJobScheduler - Rejected: a hand-rolled cron (four mechanisms, see above) · the legacy
repeat:API (deprecated, v6 removal) · an in-process timer (dies with the process; the flaw that made the closest existing tool unusable —DES-BUILD-NOT-EXTEND-PI-ROUTINES) - Must handle:
-10/-11return codes. Swallowing them makes a schedule edit silently no-op, which looks identical to success. - Carve-out that is not optional: scheduler jobs bypass
maxStalledCount— seeCONST-RETRY-INFRA-ONLY. This is the one place BullMQ's stall protection does not hold, and it is precisely the trigger that runs while nobody is watching. - Traces to:
CONST-RETRY-INFRA-ONLY,CONST-BUDGET-BEFORE-TOKENS,REQ-RUNNER-TURN-BUDGET,REQ-QUEUE-BURST-NO-DROP
- Decision: A job's forge is resolved per job, from
job.kind, at exactly one place: the worker's composition root holds aforgesmap of{ auth, host }, and the four dependencies that used to be bound to one forge —mintToken,comment,isDefaultBranchProtected,prepareWorkspace— look their forge up from the job they were handed. The interface is the one that already existed:get-token's{ mintToken, selfId, source }andgithub-host's three methods. The receiver routes by path (/gitlab, with/remaining GitHub), so each source has its own secret and its own trust regime, chosen before a byte of the body is read. - Why:
processor.mjsalready consumed those four as independently injected functions rather than as onegithubobject, so it was written against a de-facto interface and merely called it behindjob.kind === "github"guards. Making the lookup per job removed the guards without inventing anything: no abstraction was designed ahead of its second user, because the shapes were already there and the second user only revealed which parameters were wrong — a repo string where a job belonged. What the processor gained is that "forge-backed" is now the negation of local rather than a list of forges; an enumeration that forgot a forge would let it silently skip a money gate. - Rejected:
- A generic "any forge" plugin framework. Three named forges are wanted (#42, #43, #61), and one seam
discovered from a real second one beats a shape guessed from none. Still rejected at four forges,
and now on evidence rather than on principle: #43 and #61 landed together precisely so the seam
would be sized against the two extremes at once — Forgejo's transport is byte-identical to GitHub's
and all its work is semantic, Azure shares almost nothing — and what held without change was the
{ auth, host }pair andmakeForgePreparers. What did NOT hold was everything written down elsewhere: nine places said which forges exist, and the ones that mattered were the ones that failed SILENTLY (a missing receiver trigger group throws inside a reload that keeps yesterday's rules; a missing token-variable name is simply not refused inPI_FORWARD_ENV). The answer was a table those are derived from —worker/src/forges.mjs, which imports nothing so it can be the leaf of both services' graphs — not an interface for a forge to implement. - Routing by header rather than by path. Forgejo emits
X-GitHub-*on every delivery (#61), so headers cannot reliably tell forges apart — and worse, a request able to select which gate it faced would select the weakest available. A path is chosen by the operator when they configure the webhook, not by the sender at delivery time. - Negotiating the verification mechanism from what the request carries. The same reason one layer
down:
CONST-HMAC-OVER-RAW-BODY's mode is config-declared, and a delivery presenting the other mode's header is refused even when that header is correct. - A filter that performs its own membership lookup.
filter.mjsandfilter-gitlab.mjsimport nothing side-effecting, do no I/O and never throw, and that purity is exactly what makes the security-critical decision testable offline. The lookup runs in the receiver, between verification and the gate, and arrives as a plain number. - Adapting GitHub's "404 means unprotected" to GitLab. It has no such 404, and #61 records what carrying that assumption across a forge boundary costs: every branch reports unprotected and the never-merge backstop is silently disarmed. GitLab reads the protected-branches list instead.
- Inferring approval from label-application on GitLab. The premise that makes it work on GitHub is
false there (
CONST-TRIGGER-AUTHOR-GATE), so the actor's access level is resolved for every trigger type rather than only for comments. - Forking the clone path per forge. The askpass helper, the hardening flags, the gone-SHA markers and the pinned detached checkout are facts about git and this project, not about GitHub; only the remote URL and the agent's envelope differ, and both are injected. A second copy would be a second place to fix a clone bug, and the copy that did not get fixed would be the one nobody was looking at.
- One shared
postStatusComment(repo, number, …). GitLab's issues and merge requests are separate endpoints AND separate number sequences, so the method takes the discriminated target. GitHub readstarget.typenot at all — but a host method that cannot be called uniformly is not a seam.
- A generic "any forge" plugin framework. Three named forges are wanted (#42, #43, #61), and one seam
discovered from a real second one beats a shape guessed from none. Still rejected at four forges,
and now on evidence rather than on principle: #43 and #61 landed together precisely so the seam
would be sized against the two extremes at once — Forgejo's transport is byte-identical to GitHub's
and all its work is semantic, Azure shares almost nothing — and what held without change was the
- Traces to:
CONST-TRIGGER-AUTHOR-GATE,CONST-HMAC-OVER-RAW-BODY,CONST-TOKEN-SCOPED-PER-JOB,INT-GITLAB-PAYLOAD-SUBSET,INT-TRIGGERS-FILE-CONTRACT,OQ-013
- Decision: A job image declares which forges it can serve, as the label
dev.pi-dispatch.forges, and the worker's pre-spend image preflight refuses a job whose forge the label excludes. The label rides thedocker image inspectthe preflight already runs fordev.pi-dispatch.pi-version, so the happy path still costs exactly one spawn. An absent label ALLOWS everything; only a label that is present and excludes the job's forge refuses.image/verify-image.shchecks the declared list against the CLIs actually installed, so the label cannot lie. - Why:
run.imageis optional (INT-TRIGGERS-FILE-CONTRACT), and the Azure DevOps arm made that a money problem rather than a cosmetic one. Azure's only CLI is the Azure CLI plus its devops extension — roughly a gigabyte, with a Python runtime — so it ships in a separate image variant rather than in the lean, digest-pinned default. An azure trigger that forgetsrun.imagetherefore runs on the default image, finds noaz, and fails at step 3 inside a paid container, on every single delivery, looking exactly like a bad agent run rather than a missing tool. This is not a new idea so much as an existing one moved to where it helps:verify-image.shalready looped over the forge CLIs, and its comment already named this failure — "a MISSING one fails the same silent way: the agent follows an envelope naming a command". That check runs at image BUILD; the label moves the same guarantee to RUN TIME, per job, which is the only place it can catch an operator's own image (OQ-012). - The polarity is the opposite of what "declare your capabilities" suggests, and that is deliberate. The pi-version label degrades safely when missing — no label means "never resume". A forges label that refused when missing would invert that on the same parse and break every operator-built image predating it, with no warning first. A label that is present but parses to nothing usable is treated as absent rather than as "serves no forge": refusing every job over a mistyped label is a worse failure than the one the label exists to prevent.
- Rejected:
- Refusing at trigger load. The loader would have to know which images exist on which host, and
run.imagemay legitimately name an image built later. A trigger file is reviewed once; the image set changes without it. - Probing for the binary inside the container. That is post-spend by construction — the budget slot is taken and the container is running, which is the exact cost this avoids.
- One fat image. Putting a gigabyte and a second language runtime into every job container, so that the minority of deployments serving Azure need not name an image, inverts who pays.
- Refusing when the label is absent. Safer-sounding and wrong: see the polarity paragraph.
- Refusing at trigger load. The loader would have to know which images exist on which host, and
- Traces to:
INT-CONTAINER-RUNTIME-CONTRACT,INT-TRIGGERS-FILE-CONTRACT,DES-PER-TRIGGER-JOB-IMAGE,OQ-012
- Decision: One
triggers.jsonof{ on, run }entries is the single source of standing triggers for both services. A shared validator (worker/src/triggers.mjs, exported as@edgehero/pi-dispatch/triggers—@pi-dispatch/worker/triggersbefore the issue-#80 rename) parses and validates the whole file; the worker selectson.type:"cron"and the receiver selectson.type ∈ {label, comment, pull_request}for whichever forge each entry'srun.kindnames. Both validate everything; each evaluates only its own subset. This replaces the two prior files (PI_SCHEDULES_FILEandreceiver.flows.json) with no compatibility shim — a clean cutover. - Why: The schema unifies the operator's view of triggers; it does not merge the engines. The
receiver/worker, adversarial/trusted boundary is untouched: alabelonis never scheduled (no delivery GUID to dedup on, no fresh collaborator approval), and acrononnever receives a webhook. Theon × runmatrix pairscron ↔ localand webhook ↔ a forge (github|gitlab), and that pairing is the trust boundary, encoded as a fail-loud validation rule (INT-TRIGGERS-FILE-CONTRACT). One validator, run by both, means a malformed file fails both services identically — the two cannot drift. The shared module lives in the worker package becausereceiverandadminalready depend on it (today@edgehero/pi-dispatch); the dependency is one-way, so no cycle. - Rejected: a compat union accepting both old shapes (the repo bans backwards-compat shims,
.claude/rules/legacy-removal.md) · two independent validators (they drift) · a third shared package (unnecessary — the one-way worker dependency already exists). - Traces to:
INT-TRIGGERS-FILE-CONTRACT,REQ-CRON-SCHEDULED-JOBS,REQ-TRIGGER-AUTHOR-GATE
- Decision: The job image is resolved per job —
job.image ?? PI_JOB_IMAGE— from an optionalrun.imageon all four trigger kinds.PI_JOB_IMAGEremains the deployment default and the only value a deployment needs. The field is operator-authored in the reviewedtriggers.jsonand is reachable from no model-callable tool, no panel key, and not the settings overlay. A named image must be present on the host: a pre-spenddocker image inspectrefuses the job beforereserveBudgetwith a policy reason, and--pull=neverjoinsISOLATION_FLAGS. - Why:
- The toolchain is a property of the flow, not of the deployment. Verbatim the
run.packagesargument: a flow needing a Python toolchain and one needing Node + Playwright belong to the same deployment, and one image means the union of every toolchain in one tag, growing monotonically, with nothing ever removable because some other flow might need it. And a label/comment/PR trigger runs the same flows a cron trigger does — hence all four kinds, not cron only. - It changes what is in the box, never what the box can do. The argv is built by the worker:
ISOLATION_FLAGS+ the closed env map + the four mounts, none of them influenced by the image.--cap-drop=ALLbounds what runs; the image decides which code runs.CONST-ISOLATION-CONTAINER-PER-JOB's enumerated acceptance is untouched, and was checked rather than assumed. - The trust class is the file, not the field. An operator who can edit
triggers.jsoncan already point a cron trigger at any folder on the host and run any flow in it. "…and in this image" does not cross a boundary they were on the far side of.REQ-GLOBAL-PI-OVERLAYalready names this class: "operator deploy-time config — the same trust class as baking the image". - Two mechanisms for a missing image, doing different jobs. The preflight is readable, pre-spend and
non-retryable — a bare docker failure would read as infra and
CONST-RETRY-INFRA-ONLYwould have the queue pay for the retry (it did: exit 125 fell through as "unknown container exit", kept the slot, and burned a second one on the retry).--pull=nevertakes the registry out of the picture entirely. Neither is sufficient alone: the check is raceable, the flag is silent. - What this deliberately does not do: verify the image.
INT-CONTAINER-RUNTIME-CONTRACTstates the checklist,docs/job-image.mdis its operator form, the check is the operator's, and the residual isOQ-012rather than a claim.
- The toolchain is a property of the flow, not of the deployment. Verbatim the
- Rejected:
PI_JOB_IMAGE_ALLOWLIST, mirroringPI_DISPATCH_RUN_ROOTS— there is nothing model-callable to bound.PI_DISPATCH_RUN_ROOTSexists becausedispatch_runtakes a folder from the model; an allowlist is what converts a model-supplied path into a bounded one.run.imageis never model-supplied: no tool parameter, no panel key, one writer — an operator editing a reviewed file. An allowlist over a field only an operator can write is a second operator-authored file constraining the first: its only failure mode is refusing the operator's own edit, its only success mode is redundancy, and its presence would advertise a threat model this design forecloses. If a future tool ever takes an image parameter, the allowlist arrives with that tool, and this row is the reason it must.imageas a runtime settings overlay key (dispatch_set) — that is the admin-editable runtime channel, bendable by a prompt injection in the operator's session behind a confirm. Changing which code every subsequent job executes from that channel is strictly worse than changing the daily cap, whichDES-RUNTIME-SETTINGS-FILE-OVERLAYalready treats as needing an operator confirm.- An
imageparameter ondispatch_trigger_add/_edit— same channel, one level down. A confirm reading "add trigger with imagemy-python:latest" gives the operator no way to distinguish a benign tag from a hostile lookalike; the property that makesfolderconfirmable (it names a path the operator recognises) does not transfer to a ref that may be a registry, tag, digest, or typosquat.run.packagesset the precedent and it is followed exactly. - A flow-declared image, read from the serviced repo — the sharpest rejection here.
.pi/skills/<flow>/SKILL.mdis merge-gated, not operator-authored, so this would hand anyone who can land a commit on a serviced repo's default branch the choice of container. That population can already execute code inside a job container (CONST-NO-CONTEXT-FILES-MANDATORY, as amended) — but choosing the container is different in kind: it hands them the loader flags, the guardrail floor, the pinned pi version and the non-root user, i.e. every propertySECURITY.mdnames as what bounds them.DES-AI-TRIGGER-FLOW-GATEreads a boolean from that file, at a pinned SHA, precisely because a boolean is all it is willing to take from there. An image reference is not a boolean. - A second mount, or pulling the image at job time — the mount list is a constitutional enumeration and
widening it for zero new capability is the trade
DES-OPERATOR-GLOBAL-OVERLAYalready refused for staged packages. A job-time pull is worse: a network fetch of executable code, at job time, keyed on a name that just became per-trigger data — the shapePI_OFFLINE=1exists to make unreachable one layer up. Hence--pull=never. - Keep baking every flow's toolchain into the one image — the status quo and the fat-image trap.
DES-OPERATOR-GLOBAL-OVERLAY's "Bake the overlay into the image" rejection still stands and is not re-opened: models, skills, persona and staged packages ride a:romount and need no rebuild, and nothing that was a mount becomes a bake here. Whatrun.imageadmits is the case that rejection never covered — a toolchain (apt packages, language runtimes, system libraries), which a read-only mount cannot deliver at all, and for which "build your own" was always the answer the README gave. The boundary: overlay = pi configuration, one copy per deployment, mounted; image = the operating system the flow needs, per flow, built. The pulled prebuilt image stays the default and the only thing a deployment needs.
- Traces to:
INT-TRIGGERS-FILE-CONTRACT,INT-CONTAINER-RUNTIME-CONTRACT,CONST-ISOLATION-CONTAINER-PER-JOB,CONST-PI-VERSION-PINNED,CONST-RETRY-INFRA-ONLY,REQ-UPSTREAM-CONTRACT-TESTS,DES-RUNTIME-SETTINGS-FILE-OVERLAY,DES-OPERATOR-GLOBAL-OVERLAY,OQ-012
- Decision: A
pull_requesttrigger routes the event to the configured flow; the harness does not implement review-vs-push behaviour and does not change the clone ref. The worker still clones the base repo's default-branch SHA (fork-safe,INT-CONTAINER-JOB-INPUTS), delivers the PR context — number, head/base refs — as DATA in/job/event.json, and the flow (a repo skill) decides what to do with the PR viagh(review, comment, or push to the PR head branch). - Why: pi is the agent; this repo is the trigger, the queue, and the box (
no-reimplementing-pi,library-first.md). Encoding "fix the PR" vs "review the PR" in the harness would rebuild pi badly and fork the prompt per intent; naming the flow and handing it the PR context keeps the harness thin and lets the same machinery serve any PR workflow. Keeping the clone ref at the base default-branch SHA preserves the fork-safety property the isolation design already relies on — attacker-controlled head bytes are never executed by the host or baked into the system prompt. The job-data carries a discriminatedtarget: { type: "issue" | "pull_request", number, title, body, head?, base? }rather than a compat union of flat fields. - A submitted review routes through this same decision (issue #66).
review_submittedis an action on this trigger type rather than a type of its own, and it changes nothing about the clone ref, the target shape or who decides what to do: the harness hands the flow the PR context plus the review's own{ id, body, state, author_association }and the flow decides whether that means push, comment or nothing. Two consequences follow from routing rather than interpreting. Thestatereaches the flow as data, so "only act on changes_requested" is a flow decision, whileon.reviewStateis the operator's separate, cheaper control over what is worth paying for at all — the same split as a label predicate versus what the skill does once it runs. Andreview.idis carried because the review's inline comments ride an event this project does not ingest, so fetching them is the flow's job and the id is what it needs to do it. - Rejected: cloning the PR head ref in the worker (executes fork code on the host clone path, and a
base-scoped token cannot push to a fork branch anyway) · harness-side review/push logic (reimplements pi) ·
auto-firing on any PR open (unbounded paid runs from fork PRs —
CONST-TRIGGER-AUTHOR-GATE) · a fifthon.typefor reviews (GitLab'sapprovedalready ridespull_request, so a new type would make one forge's review a type and the other's an action) · ingestingpull_request_review_comment(one delivery per line comment, a volume characteristic nothing else here has; the empty-body refusal plusreview.idis the cheap answer until there is a reason to do more). - Traces to:
CONST-TRIGGER-AUTHOR-GATE,INT-WEBHOOK-PAYLOAD-SUBSET,INT-CONTAINER-JOB-INPUTS,CONST-ISOLATION-CONTAINER-PER-JOB
- Decision: Local-folder jobs are triggered through three producers, each calling
enqueueLocalJobdirectly: the CLI (pi-dispatch run <folder> --task … [--flow …]), the first and operator-typed interface; the admindispatch_runtool/command (DES-ADMIN-VIA-PI-EXTENSION); and the worker's outbox collector (DES-JOB-OUTBOX-CHAINING). The CLI is the terminal entry point a local operator reaches for first; the other two are the AI-triggered paths, gated byDES-AI-TRIGGER-FLOW-GATE. - Why: For a self-hosted tool that mostly runs on people's own machines, the terminal is the natural first interface — no web server, no bigger build before anything runs — and it is what makes the local path usable early, without the GitHub-webhook receiver a local user does not need.
- Consistency check (this was verified, not assumed): no producer violates a constraint.
CONST-TRIGGER-AUTHOR-GATEis webhook/comment-scoped by construction, and local jobs are ungated by design (SECURITY.md: local CLI access is the trust boundary for local). Critically,CONST-BUDGET-BEFORE-TOKENSstill holds: the cap is checked and incremented in the worker's processor, immediately before the container starts — never in a trigger. A producer that enqueues a job cannot bypass the budget, because the budget gate lives on the consumer side, after prepare and beforerunContainer. All three producers are only producers; they spend nothing. The depth/count/rate caps on the AI-triggered producers (PI_CHAIN_DEPTH_MAX,PI_CHAIN_MAX_PER_JOB, thedispatch_runper-hour rate limit) are additional producer-side defense-in-depth, never a substitute for the consumer-side cap that is the actual money bound. - Safety: a local job edits the folder in place with no undo, so a producer refuses a dirty git
working tree. The guard is mirrored at each producer: the CLI honours
--force; thedispatch_runtool/command has no force option, so an injected call cannot wave the guard away. The outbox collector carries a single same-folder-chain exception: a chained job continuing on the parent's own folder skips the guard, because there the "dirty" tree is the parent agent's deliberate output — the handoff the child builds on — not a human's uncommitted work. A chain targeting a different folder is out of this slice and would still enforce the guard; the exception is scoped to same-folder chaining only. - Traces to:
CONST-BUDGET-BEFORE-TOKENS,DES-ADMIN-VIA-PI-EXTENSION,DES-AI-TRIGGER-FLOW-GATE,DES-JOB-OUTBOX-CHAINING,REQ-LOCAL-JOB-VISIBILITY
- Decision: The workspace CLI (
pi-dispatch, bin of the worker package) is the deployment's operator surface, and its subcommands sit on an explicit gate ladder. Read-only / always safe:doctor,status. Operator-typed, ungated:run,pause,resume,sandbox,import-pi(each is its own gate — typing it is the approval,REQ-ADMIN-VIA-PI-EXTENSION's ladder top). Create-only, contractually non-destructive:init(idempotent scaffolds; an existing file is never touched). Consented host mutations:upanddoctor --fix— each concrete action (a docker pull of the deployment's own default image, a loopback Valkey start, an overlayauth.jsondelete, animport-pirestage) is shown verbatim and runs only on an explicit y/N accept, defaulting to No, including on non-TTY stdin. The receiver'spi-dispatch-receiverbin is a sibling on the same ladder (serve = the operator typed it). - Why:
initanddoctorgrew organically with no recorded surface; issue #80 adds subcommands that mutate the host, and an unrecorded gate ladder is how a later "helpful" flag erodes a trust property nobody wrote down. Recording which tier each subcommand sits on makes "may this be automated?" a lookup instead of a debate. - The never-tier is load-bearing: no subcommand, flag, or fix path may rewrite malformed config
(fail-loud/keep-last-good is doctrine), write triggers/pause-windows content, pull a
trigger-named
run.image(each image is a separate per-flow trust posture — only the deployment's default is ever offered, and the consent keypress is the "pulled it onto this host yourself" actSECURITY.mdrequires), guess a semantic env value, or touch branch protection on a forge. - Traces to:
REQ-DEPLOYMENT-BOOTSTRAP,DES-CLI-TRIGGER-FOR-LOCAL,CONST-BUDGET-BEFORE-TOKENS
- Decision:
pi-dispatch setup githubmints GitHub App credentials via the App Manifest flow: a throwaway loopback HTTP listener serves a self-submitting form that POSTs the manifest togithub.com/settings/apps/new(or the--orgvariant), the browser redirect delivers a single-use code (1h validity), and the unauthenticatedPOST /app-manifests/{code}/conversionsreturns the app id, private-key PEM, and webhook secret in one response. The wizard then shows the exact.envlines and writes them only on one explicit consent; the PEM lands beside.envwith mode0600(an existing key file refuses — never clobbered); an already-setWEBHOOK_SECRETis kept (setEnvKeyIfEmpty— replacing it would invalidate working deliveries); the installation id is discovered via an app JWT againstGET /app/installationsafter the operator confirms installing.--no-webhookcreates the App withhook_attributes.active:false— the no-public-URL shape the polling transport consumes. - Why: The App source is the strongest credential this system supports (SECURITY.md prefers it),
but acquiring it was the most manual mile in the whole setup: five settings pages, a hand-invented
webhook secret, and an installation id hunted from URLs. The manifest flow compresses all of it into
one click without changing whose infrastructure is trusted: the listener is the operator's own
loopback, GitHub is the only remote party, and no maintainer-controlled service ever sees a
credential. The JWT for installation discovery is ~15 lines of
node:cryptoRS256 on purpose — auditable, dependency-light, and used once at setup time (job-time minting stays@octokit/auth-appinget-token.mjs, unchanged). - Gate ladder fit (
DES-CLI-SURFACE): operator-typed, and every write individually consented with the content shown first; secrets never printed, redacted in every error path. There is deliberately no--yeshere — unlikeup's docker actions, these writes carry credentials. - Rejected: shipping a maintainer-registered OAuth client for device flow (inserts a maintainer dependency into a self-hosted trust chain); auto-installing the App (the install page is a GitHub consent screen — automating a consent screen defeats it).
- Traces to:
DES-CLI-SURFACE,REQ-DEPLOYMENT-BOOTSTRAP,CONST-TOKEN-SCOPED-PER-JOB,SECURITY.md(auth-source ladder)
- Decision:
pi-dispatch-receiver pollis an alternative producer for GitHub triggers that needs no public URL: it polls issue events, issue comments, open PRs, and the reviews on those open PRs per serviced repo (ETag conditional requests, ~60s cadence honoringx-poll-interval), synthesizes the exactINT-WEBHOOK-PAYLOAD-SUBSETshapes, and feeds the unchanged purefilter()and the shared enqueue path withpoll-*delivery ids. The webhook receiver stays the default and the documented low-latency path — polling is for hosts that cannot (or should not) expose a port. Repos come from an explicitPOLL_REPOSallowlist or, under App auth, the installation's repo list. A fresh poller initializes cursors to now and never replays history — a label applied months ago was an approval for a different moment. One repo's API failure skips that repo for the cycle, never the loop.WEBHOOK_SECRETis not required in poll mode (there is no inbound delivery to verify);servestill hard-requires it. - Why: The public HTTPS endpoint was the single hardest setup mile for home deployments (tunnel
or DNS, both punted to the operator), and it defends a surface polling simply does not have: TLS
with the operator's own credential against api.github.com replaces HMAC-over-raw-body because the
authentication points the other way. Every author/label/bot-loop/dedup/spend gate evaluates
identically — parity is pinned by tests running webhook-shaped and REST-synthesized subsets through
the same gate. The accepted cost is ~60s trigger latency, cheap against jobs that run for minutes.
Conditional 304s are rate-limit-free, so a handful of repos polls within a fraction of the 5000/hr
budget.
The reviews source is per-PR, and that shapes its cost model (issue #66).
GET /pulls/{n}/reviewshas no repo-wide form, so a cycle would otherwise spend one request per open PR forever. The validators therefore live in one hash keyed by PR number rather than a key per PR — an unbounded key family would break the "refresh the cursor family as a unit" TTL argument this design rests on — and the idle steady state is one 304 per open PR, which costs no quota. The sweep is bounded and says so in a log line when it truncates. It also runs when the open-PR list itself answers 304: whether submitting a review perturbs that list is GitHub's business, and betting correctness on it would mean review triggers that fire only when something else happens to touch the PR. The cursor is persisted once per sweep, not per review, because many endpoints' ids interleave — advancing per item would either re-enqueue or strand, and a mid-sweep failure retrying the whole sweep is idempotent on thepoll-rv<id>jobIds. Reviews on closed PRs are never synthesized: the open list is the sweep set, and a job started by a review of a merged PR has nothing left to act on. This source exists because the alternative was areview_submittedtrigger that loads clean and can never fire under polling, which is the silently dead config this project refuses everywhere else. The polling credential is not a job token. Under App auth the poller mints an unscoped installation token for itself — it must callGET /installation/repositoriesand read every serviced repo, which a per-repo-scoped mint cannot. That token never reaches a job:CONST-TOKEN-SCOPED-PER-JOBgoverns what jobs receive, and the worker's per-repo mint is untouched. - Rejected: a GitHub Actions self-hosted runner as the transport — outbound-only networking,
yes, but it executes merge-gated workflow code on the worker's host, converting merge-to-default
into host-level code execution outside the container boundary; the one transport that removes the
same friction while making the trust model strictly worse. Also rejected: polling as the default
(latency and rate-limit budgets are real; the receiver remains first), and any body interpretation
in the poller (
CONST-ISSUE-TEXT-IS-DATA— text stays data all the way through). - Traces to:
DES-GH-APP-MANIFEST-SETUP(--no-webhookmints the hook-inactive App this mode pairs with),INT-WEBHOOK-PAYLOAD-SUBSET,REQ-DEDUP-BY-DELIVERY-GUID,DES-TRIGGER-OUTSIDE-PI
- Decision:
/dispatch setupis the default setup route (issue #96): when bare/dispatchdetects no deployment anywhere, it lands directly in the wizard's opening choice — the select (Guided setup / Open the panel anyway / Cancel) is the consent, replacing the earlier yes/no offer; a configured deployment with a down queue still never enters the wizard (the unreachable banner rule is load-bearing and unchanged). The flow: choose a deployment dir (default~/pi-dispatch, never the repo) → Docker pre-check (a capture probe distinguishing not-on-PATH from daemon-down; on failure a Re-check / Continue anyway / Stop loop with per-OS pointers — never a piped installer;up's own hard refusal stays as defense in depth) → consentednpm install @edgehero/pi-dispatch@<RUNTIME_VERSION>into it → hand the terminal tonpx pi-dispatch up→ optionalservice install→ write the deployment pointer (INT-DEPLOYMENT-POINTER-CONTRACT, JSON shown verbatim) → provider key printed, never written → optionalsetup githubhand-off → trigger-edge choice (install the webhook receiver as a service — a consentednpm install @edgehero/pi-dispatch-receiver@<RECEIVER_VERSION>thenservice install --receiver; or run it with docker compose — the compose file ships in the runtime package and is copied create-only into the deployment dir before a consented--profile receiver up -d; or show the polling command; or skip) → optional first trigger for the repo the session sits in (cron pre-filled with the folder, flow picked from its.pi/skills/via the sharedSKILL_NAME_RE, the in-place-edit warning printed, theai-trigger: allowline printed, never written — repo files stay the operator's, and the gate reads committed HEAD anyway) → the panel. A once-eversession_startnudge (reason: "startup", notify-only, marker-file latch) points at the command, and a once-per-process skew notice on bare/dispatchsays when the deployed runtime is older than the console's pin ("run /dispatch setup to upgrade" — re-running setup is the upgrade path, made safe by the post-install version assertion). Both version pins carry anti-drift tests (RUNTIME_VERSION↔ worker,RECEIVER_VERSION↔ receiver). Every step is individually declinable and declines continue converge-style. - Mechanics, chosen for pi's real constraints: dialogs-first, overlay-per-handoff — the
wizard is a plain
ui.select/input/confirmchain, and each terminal-needing child runs inside a short-livedctx.ui.customoverlay that exists only to hold thetuihandle for the suspend bracket (tui.stop()→stdio:"inherit"spawn →finallyrestore), because the handle exists nowhere else, dialogs cannot run while an overlay captures focus, and stdin is unreadable while suspended — the child's own prompts (up's y/N gates) own the terminal, which is exactly right: the child's consent gates are the host-mutation consents, and the wizard never forwards--yesto anything. The npm step follows import-pi's spawn doctrine (barenpm, win32npm.cmd+shell:trueonly there, no filesystem path in argv — the dir rides incwd:,--ignore-scripts, post-install version assertion).--ignore-scriptson our own runtime is safe by inspection: bullmq/ioredis are pure JS and msgpackr's native accelerator is an optional dep (--omit=optional) with a JS fallback. - Gate-ladder fit (
DES-CLI-SURFACE): the wizard adds no new tier and no new powers — it sequences existing consented commands through their own gates, writes deployment config through the same validated atomic writers the panel uses, and its only novel artifact is the pointer file, which resolves paths and grants nothing. Operator-typed only: there is deliberately no model-callable setup tool — the bundled skill tells the model to ask the operator to type it. - Rejected: a long-lived wizard overlay with hand-rolled input (reimplements dialogs, fights the
focus model); reusing a clone as the runtime (the persona has none; two code-delivery paths double
the matrix); a detached background worker (an unsupervised long-lived process nobody consented to
manage — the service step or a printed command instead); credential-entry dialogs (secrets never
transit the extension); auto-writing
ai-trigger: allowinto the operator's repo (the two-key property — repo consent and operator trigger — must stay two keys). - Traces to:
INT-DEPLOYMENT-POINTER-CONTRACT,REQ-DEPLOYMENT-BOOTSTRAP,DES-CLI-SURFACE,DES-GH-APP-MANIFEST-SETUP,DES-TRIGGER-OUTSIDE-PI(the wizard bootstraps host processes; it never hosts them)
- Decision: The worker runs on the host (a Node process,
pi-dispatch worker/npm start), not in a container. It launches job containers by shelling out to the realdockerCLI.docker-composeruns only Valkey; the worker is a host process alongside it. - Why: This is the reversal of
DES-JOB-FILES-VIA-VOLUME-SUBPATH(below, superseded), forced by two findings and the local-first target. (1) ThedockerCLI translates host paths; the daemon does not. On Docker Desktop the daemon is a Linux daemon behind a Windows named pipe (docker contextshowsnpipe://…withdocker inforeportinglinux/x86_64), soC:\Users\…is not a path a Linux kernel can bind-mount. Translation is client-side — corroborated by compose'sCOMPOSE_CONVERT_WINDOWS_PATHSrewriting paths even against a remote non-Windows daemon. So a containerised worker calling the Engine API must construct the VM-internal path itself, and that prefix has already moved between Docker Desktop versions (/host_mnt/c/…→/run/desktop/mnt/host/c/…). Pinning bespoke path math to an undocumented, moving internal isCONST-PI-VERSION-PINNED's failure class with a different vendor — it would break on a silent Docker Desktop auto-update. A host worker shelling out todockerinherits Docker's own cross-platform-tested translation for free. This islibrary-firstone level up: do not reimplement Docker Desktop's path translation. (2) Local-folder jobs require a host bind mount. The named-volume trick in the superseded entry dissolves the path problem only because no host path crosses the boundary — which is exactly what a local-folder job cannot do: the operator's own folder must be bind-mounted as/workspace, edited in place. There is no volume to hide behind. Since local folders are the primary self-hosted experience, the deployment model must support the bind mount, and the host worker does so with zero path math. Isolation is unaffected.CONST-ISOLATION-CONTAINER-PER-JOBis about the job container being the boundary — the harness still never runs pi on the host. And a container holding/var/run/docker.sockis already root-equivalent on the host, so containerising the worker bought no isolation; it only bought deployment tidiness, which is what this trades away. - What this deletes: the named-volume +
volume-subpathmachinery, the≥26.1.0Engine floor, the socket mount, and thedocker-socket-proxy. The worker binds/job:roand/workspace(the folder) directly. - Accepted cost, stated plainly: Node on the host, not only Docker.
docker compose upalone no longer runs everything; the operator also runspi-dispatch worker. That is the honest price of local-folder jobs working on Windows/macOS/Linux without fragile path math. The receiver is Node too, so it is one install story (npm ci), with Docker running Valkey and the job containers. Since issue #80,pi-dispatch upsequences the surrounding chores (image pull+tag, Valkey start, scaffold, preflight) behind explicit per-action consent — the price above is unchanged (the worker is still a host process the operator runs); only the amount of typing shrank (REQ-DEPLOYMENT-BOOTSTRAP). - Evidence: verified first-hand this session —
docker context(npipeendpoint,linux/x86_64daemon) ·moby/for-win#14271(VM prefix/run/desktop/mnt/host/…) anddocker/compose#5563(older/host_mnt/…), i.e. the prefix moved ·docker/compose#4240(COMPOSE_CONVERT_WINDOWS_PATHSis unconditional client-side rewriting) · a constructeddocker runargv launched the real job image with a host-native folder path, cross-platform, with no translation. - Rejected: containerised worker +
volume-subpath(cannot bind-mount a local folder; pins moving path math) — see the superseded entry for its full reasoning, kept as the record of why it was tried. - Traces to:
INT-CONTAINER-JOB-INPUTS,INT-CONTAINER-RUNTIME-CONTRACT,CONST-ISOLATION-CONTAINER-PER-JOB
- Status: SUPERSEDED by
DES-WORKER-ON-HOST. Kept in place because IDs are permanent and its research (subpath semantics, the socket-proxy hardening) stays relevant if a GitHub-only deployment ever re-containerises the worker. The decision below is not what the code does: the worker runs on the host and bind-mounts directly. - Decision: The worker hands
/workspaceand/jobto the job container via a plain named volume per job plus--mount type=volume,…,volume-subpath=…. Never a host bind mount. Docker Engine ≥26.1.0. Atecnativa/docker-socket-proxysits between the worker and the Docker socket. - Why: The worker is containerised and launches sibling containers through the Docker socket, so
every bind-mount path it passes to
docker runis resolved in the daemon's filesystem namespace, not its own.-v ${JOB_DIR}/workspace:/workspacetherefore mounts an empty directory or the wrong one — silently. This is the classic docker-out-of-docker footgun and it was this project's largest named unknown for cross-platform support. A named volume dissolves the problem rather than managing it: a volume is a daemon-side handle, so no host path string ever crosses the boundary and there is nothing to translate. Every competing option manages the mismatch per-platform instead of removing it, and each breaks somewhere: same-path bind mounts work on Linux and mostly on macOS but break on native Windows, where the daemon lives in a VM with a POSIX namespace and drive-letter paths are translated rather than passed through — they only hold if the whole stack runs inside WSL2, which is a discipline, not a guarantee. Worker on the host is technically fine but abandons the compose deployment model and reintroduces "install Node correctly on three operating systems" as a support burden.docker cpavoids paths too but cannot give/joba kernel-enforced read-only mount, whichINT-CONTAINER-JOB-INPUTSdepends on — it is the viable fallback, not the default. Two constraints are not optional. The volume must be plain: a "parameterized" named volume that is a disguised bind (driver_opts: {type: local, o: bind, device: …}) mis-concatenates the subpath into the mount options and fails. And the subpath must already exist inside the volume before the container starts — there is no auto-create; the worker createsworkspace/andjob/as job prep. The socket makes the worker the root-equivalent asset — not the agent, which never receives it. That residual risk is supply-chain, not injection (worker code never reads issue text, perCONST-ISSUE-TEXT-IS-DATA), and the socket proxy bounds it: allowlistCONTAINERS/IMAGES/POST, leaveEXEC,SECRETS,SWARM,PLUGINSdenied by default. - Evidence (upstream):
moby/moby#45687("volumes: Implement subpath mount", Engine 26.0.0; symlinks cannot escape the volume base; TOCTOU-protected) ·docker/cli#4331(CLI flag) ·moby/moby#47842(subpath must pre-exist; failslstat …: no such file or directory) ·moby/moby#47711(subpath dropped in Swarm; fixed 26.1.0 — hence the ≥26.1.0 floor even though we do not use Swarm) ·forums.docker.com/t/volume-subpath-in-docker-compose/143463(bind-backed named volume breaks subpath:invalid mode: rw,nocopy,tftp; reproduced on 26.0.1–27.1.2) · Docker Desktop FAQ: "Mac and Windows WSL 2 users can connect via Unix socket atunix:///var/run/docker.sock" ·Tecnativa/docker-socket-proxyREADME (per-API-section allowlist;POST/AUTH/SECRETSrevoked by default) - Rejected (at the time of this superseded entry): same-path bind mount (breaks on native Windows) ·
worker-on-host — this rejection was itself reversed by
DES-WORKER-ON-HOST, which found that local-folder jobs make a host bind mount unavoidable and that the containerised alternative pins moving path math ·docker cp(cannot enforce read-only/job; kept as fallback) - Open:
readonlycombined withvolume-subpathis documented as an orthogonal field but no worked example was found combining them — smoke-test it in CI before relying on it, becauseINT-CONTAINER-JOB-INPUTSis a security boundary, not a convenience. Likewise--rmis believed not to touch named volumes (it removes only anonymous ones) — inferred, not verified. - Traces to:
INT-CONTAINER-JOB-INPUTS,INT-CONTAINER-RUNTIME-CONTRACT,CONST-ISOLATION-CONTAINER-PER-JOB
- Status: SUPERSEDED by
DES-ADMIN-VIA-PI-EXTENSION. The port-separation reasoning below was correct for a networked admin panel — the panel and the internet-facing receiver have opposite reachability requirements and cannot share a port — but the successor removes the network surface entirely: the admin surface is a pi extension in the operator's own session, binding no port at all. Kept in place because IDs are permanent and inboundTraces toreferences remain valid. - Decision: The admin panel is a separate process on a separate port, binding
127.0.0.1by default. Bull Board mounts on the panel. The receiver carries no dashboard and no admin surface. - Why: The panel sets the model, the budgets, and what the agent is told to do — it is the most
dangerous surface in the system, and a compromise of it is a compromise of everything downstream of it.
The receiver is the one process that must bind
0.0.0.0, because GitHub has to POST to it from the internet. Mounting the panel on the receiver therefore publishes the admin surface to the internet — the two processes have exactly opposite reachability requirements, so they cannot share a port. This is a correction: the source design document mounted Bull Board on the receiver behind basic auth. That was defensible when the dashboard was read-only; it is not once the same surface can change the model and rewrite flows. Basic auth on a public port is not the control that should stand between the internet and "edit the agent's instructions". Note the deliberate asymmetry withDES-BUILD-NOT-EXTEND-PI-ROUTINES, which criticises that project for hard-binding127.0.0.1. That criticism was of a webhook trigger endpoint, which is useless if unreachable. For an admin panel the same bind is correct. The lesson is that reachability is a per-surface decision, not a project-wide default — which is exactly why they are different processes. - Rejected:
- Panel mounted on the receiver behind basic auth — publishes admin to the internet; see above.
- Panel on the same process, different port — one crash, one dependency upgrade, or one unhandled rejection takes down webhook ingress with the admin UI. The wait-list should not depend on the UI.
- Traces to:
CONST-TRIGGER-AUTHOR-GATE,CONST-BUDGET-BEFORE-TOKENS,REQ-JOB-STATUS-COMMENTS
- Decision: The admin surface is a pi extension shipped in an
admin/workspace, loaded into the operator's own interactive pi session (via-e,~/.pi/agent/extensions, or a trust-gated.pi/extensions). It provides operator-only slash commands (/dispatch status|pause|resume|runs|logs|budget|triggers|insights|settings|set|unset) and one self-refreshing TUI overlay component with four in-component views: LIST — a framed, theme-colored panel (color via pi's injectedTheme, applied post-layout so pi's ANSI-awarevisibleWidthstill frames it) carrying a status header, day/week/month SPEND meters (colored by the samewindowStatethe worker enforces) plus a daily token counter, a unified TRIGGERS pane whose{on, run}rows are selectable and editable (a cron row carries an amber⚠ overdue/⚠ stalledbadge joined from its resident scheduler — health is a LIST-level fact, not only a drill-in one), and an interactive runs list rendered as a cursor-following viewport (10 rows over the read model's 50-record window,↑/↓ N moreedge markers) — navigated with↑↓,Tabjumping between the trigger and run section heads,ocycling the runs order (time → tokens → cost → outcome; absent numbers sort last, the active order is named in the runs divider, and Enter opens the row the sorted list shows), andljumping straight to the live tail of the active job (inert without one — the key the footer always advertised); TRIGGER_DETAIL — Enter on a trigger opens its filter and a per-kind trust model (who authorizes it, how it dedups, which service owns it), witheedit-flow andxdelete, the delete armed as an in-frame y/n: onlyycloses the overlay, carryingconfirmed: trueso the command loop skips the duplicatectx.ui.confirmwhile the write still goes through the shared validate-then-renamewriteTriggers— the question costs a keystroke, not a dispose/reopen cycle; RUN_DETAIL — a drill-in dump of the selected run's PII-free.jsonrun-record fields, walked in place with←/→(one sandbox read per record through the seam; the LIST cursor follows so Esc lands on the record being read); and LIVE_TAIL — a view that tails a running job's.loginside the overlay through an injecteddeps.tailLogseam whosefsread lives inindex.ts(the log CONTENT staysclip-stripped and uncolored — only the chrome is themed), opening pinned to the bottom in follow mode: scrolling up pauses following, reaching the bottom re-arms it, and the footer names the state (follow/paused) so stale lines cannot pass as live. Analytics live on the insights artifact (REQ-INSIGHTS-HTML-EXPORT, issue #181), the ONE surface for "what is this deployment wired to do and what does it cost": bare/dispatch insights(and the overlay'sikey, which resolves the overlay with a done-action soindex.tswrites and opens the page between overlays — the addTrigger route, no dep seam, no TUI suspend bracket) writes the self-contained page atomically to the stable path<graphDir>/insights.html(tmp+rename — stable so re-running updates an already-open tab through the page's own Reload/auto-reload controls, atomic so that tab's reload never reads half a file), prints thefile://URL first and best-effort opens the platform browser through the worker's shared opener — skipped and said over SSH or without a display,--no-openalways;insights whatifkeeps the re-pricing estimator as a command (its reply goes through the admin channel like every read). The dashboard itself carries no analytics fetch paths at all anymore: the two per-view refresh policies the removed COSTS/GRAPH views needed (the stale-gated tick piggyback; entry-plus-r-only around the git-spawning enumeration) left with them, and the overlay is back to one snapshot poll plus the tail read. The extension still binds no network port at all: a file with no server is not a surface — nothing listens, nothing off-machine gained reachability — so this stays strictly narrower than the superseded127.0.0.1panel. The artifact carries run-record fields and operator-authored trigger/skill strings only, never.logbytes and never a host path beyond the folder's basename — the same placement boundary as everything else here, applied to a file that outlives the session. The LLM-callable tools are reads (status/runs/triggers/costs—dispatch_costsreturns the fold as JSON whose every monetary value carries itsclass, so a model consuming it cannot launder an estimate into a fact),pause/resume, the gateddispatch_runenqueue, and the confirm-gated writesdispatch_setanddispatch_trigger_add/_edit/_delete. A write can be operator-typed (ctx.uiselect/input/confirmdialogs from the overlay, or a/dispatch setcommand) or model-initiated but operator-approved: a write tool routes throughconfirmedWrite, which applies the change only after the operator approves actx.ui.confirmshowing the concrete before/after, and refuses — writing nothing — when no interactive operator is present (ctx.hasUIfalse). The model emits the call; the human answers the confirm. Both paths reach the samewriteTriggers/writeSettings(validated by the sharedparseTriggers/writeOverlay, atomic tmp+rename, fail-closed) and both services live-reloadtriggers.json/settings.json, so a change takes effect without a restart (OQ-008), keeping the running config on an invalid edit. The extension also ships anoperate-pi-dispatchskill (advertised via theresources_discoverevent) that recommends how to use those human confirm gates. The extension talks to the same Valkey (VALKEY_URL) and reads the run-history sidecar files; it binds no network port at all. Bull Board is dropped. Since issue #54 the read-model additionally carries the graph's data surface: it enumerates a cron folder's committed skills from the git object store at HEAD (readFolderSkills— the worker's ownselectEntries/keepOnlyDeclaredSkillsover one hardenedls-tree, plus one boundedcat-fileper SKILL.md), lists a trigger's injected skills dir advisorily (readInjectedSkills), and folds already-scanned run records into the per-trigger and flow→flow joins (cronRunStats,joinRunsToTriggers,observedChainEdges) — all never-throw, degrading per folder, bounded by the literal-pinnedGRAPH_LIMITS, and all insideread-model.mjs, so the dashboard's fs ban and the.logplacement boundary are untouched. The enumeration is display-advisory: the chain gate's truth staysreadFlowGateat a pre-agent sha (DES-AI-TRIGGER-FLOW-GATE), and no graph badge is a gate decision. - Why:
- The receiver still carries no admin surface — ever. The superseded panel narrowed that surface to
a
127.0.0.1bind; a surface with no port at all is strictly narrower still. Nothing reachable from the network gained a control here — it lost one. - Reconciliation with the amended
CONST-ISOLATION-CONTAINER-PER-JOB. pi does run on the host here, as the operator's own interactive tool — which the amended constraint scopes out: this session is not a harness invocation, processes no adversarial input, is operator-present, and holds no harness credentials. This mirrorsDES-WORKER-ON-HOST's "isolation is unaffected" reasoning and clears the higher bar explicitly rather than by omission — the harness still never invokes pi on the host, and.claude/rules/agent-isolation.md'sno-pi-outside-containertargetsreceiver/worker/imagepaths, none of which theadmin/extension is, so that rule is untouched. - The asymmetry with
DES-TRIGGER-OUTSIDE-PI. That entry rejects a pi-extension trigger because a session-bound lifetime contradicts an always-on trigger. The admin surface has the opposite lifetime requirement: it is useful only while the operator is present, so a session-bound lifetime is correct for it and disqualifying for a trigger. Reachability, and now lifetime, are per-surface decisions. - The injection boundary holds by placement, not by filtering. Raw container
.logoutput is untrusted agent-adjacent text and never enters LLM context — only the fixed-enum, PII-free.jsonrun records may; the.logis overlay-viewer-only. The LIVE_TAIL view preserves exactly this boundary: the injecteddeps.tailLogseam renders.logbytes in-overlay only — never a tool result, never model context — and the USED_API surface stays the three pi members, becausetailLogis an internal overlay dependency injected through the existingcustomseam, not a pi member. This is the same structural defence asCONST-ISSUE-TEXT-IS-DATA, one layer down: the boundary is where the data is placed, not a filter over its content. One residual is named and accepted: a prompt injection in the operator's session can invokedispatch_pause/dispatch_resume, accepted because the outcome is durable-but-reversible and money-safe — neither tool spends tokens nor raises the cap (CONST-BUDGET-BEFORE-TOKENS), so the worst case is a queue stall the operator observes and undoes. A second residual is named but is NOT money-safe, and is bounded by structure rather than by reversibility: a prompt injection in the operator's session can invokedispatch_run, which enqueues a paid run that edits a folder in place with no undo. This supersedes the Decision's "reads pluspause/resumeonly" categorical —dispatch_runis a third model-callable tool, an enqueue, admitted underDES-AI-TRIGGER-FLOW-GATEand its companion requirement (therequirements.mdamendment lands in a sibling task).dispatch_runstill takes no spend knobs. The injected call is bounded by six independent limits, not by undo: (1) the operator-preconfigured folder allowlist (PI_DISPATCH_RUN_ROOTS, realpath + containment) — the tool can fire only inside folders the operator chose; (2) the per-flow committed opt-in (default deny, read at a pre-agent SHA,DES-AI-TRIGGER-FLOW-GATE); (3) the final dirty-tree refusal — the tool has no force option (DES-CLI-TRIGGER-FOR-LOCAL); (4) no spend-knob params on the tool —model,maxTurns,dailyCap, andconcurrencyare not tool arguments; they resolve from the overlay/env perDES-RUNTIME-SETTINGS-FILE-OVERLAY, so an injected call cannot widen per-job spend; (5) a per-hour rate limit ondispatch_run; and (6) the daily cap (CONST-BUDGET-BEFORE-TOKENS), the ultimate money bound, resolved consumer-side in the processor. The money-safe framing therefore applies only todispatch_pause/dispatch_resume, not todispatch_run. A third residual is named and bounded by a human confirm, not by structure: the model-callable write toolsdispatch_setanddispatch_trigger_add/_edit/_deletecan change a limit (the daily cap included) or add a paid trigger. Each routes throughconfirmedWrite, which refuses unless the operator is present (ctx.hasUI) and approves actx.ui.confirmdialog showing the concrete before/after — so a prompt-injected session emits only the call; the approval is a human keypress it cannot forge, and with no interactive UI (print/headless) the write is refused, not silently applied. This is the same human-approval gate the operator-typed/dispatch setand overlay CRUD already were; it does not weakenCONST-BUDGET-BEFORE-TOKENS(the cap is still checked before tokens — only its value changes, under an operator confirm, exactly as a typedsetwould) orCONST-TRIGGER-AUTHOR-GATE(whose webhook author-gating is untouched; the confirm is the human approval for a locally-configured trigger). The residual that remains is an inattentive operator rubber-stamping a confirm; the dialog defaults to deny and shows the concrete change to make that a deliberate act, and theoperate-pi-dispatchskill tells the model to state the change plainly and to accept a decline rather than retry it. Strictly, tool absence was safer than a confirm — that trade is taken deliberately to make the surface AI-operable, and the write tools aresequentialso two writes cannot interleave. - The operator's pi version is uncontrolled, so the extension runs a load-time capability probe
of the exact API surface it uses and, on any miss, registers nothing — all-or-nothing rather than
half-loading. The supported version is the pin,
0.80.7(CONST-PI-VERSION-PINNED). Residual risk, named: an operator on a divergent pi version gets no admin surface and falls back to direct Valkey and file inspection, rather than a silently degraded one.
- The receiver still carries no admin surface — ever. The superseded panel narrowed that surface to
a
- Rejected:
- A served graph page (a localhost listener for the HTML view, or live data via a socket) — the
exact surface this entry removed, re-proposed with a prettier face; the socket→file substitution
DES-JOB-OUTBOX-CHAININGcanonised applies symmetrically here, so the browser view is a written artifact and refresh is the page reloading a re-written file, never a connection. - Raw
.logbytes in the HTML artifact — the.logis untrusted, PII-bearing text whose boundary is placement (overlay-viewer-only), not filtering; an escaped copy in a durable file outside the overlay would trade that structural defence for an HTML-escaping promise, the one trade this design refuses everywhere else. - The web panel + Bull Board — an entire localhost web app for a solo, terminal-native operator. The
superseded
DES-PANEL-SEPARATE-FROM-RECEIVERholds the full original reasoning; it was correct for a networked panel and is removed because the network surface is removed. - Mounting admin on the receiver — unchanged rejection: the receiver must bind
0.0.0.0, so any admin surface on it is published to the internet. - The extension spawning pi as a subprocess — would violate
no-pi-outside-containerand the amended constraint's harness-invocation scope. The extension runs inside the operator's session; it does not launch an agent.
- A served graph page (a localhost listener for the HTML view, or live data via a socket) — the
exact surface this entry removed, re-proposed with a prettier face; the socket→file substitution
- Evidence (upstream): read from the published pinned artifact
@earendil-works/pi-coding-agent@0.80.7(npm), not HEAD —dist/core/extensions/types.d.ts(registerCommand:876,registerTool:874,ExtensionUIContext.custom:116-126) ·docs/extensions.md(extension commands run without model involvement; loading via-e/~/.pi/agent/extensions/ trust-gated.pi/extensions) ·examples/extensions/(doom-overlay, an interactive TUI overlay; the with-deps example resolves its ownnode_modulesvia jiti). The load-time capability probe exists precisely because these are asserted against the pin, not against a moving HEAD. - Traces to:
CONST-ISOLATION-CONTAINER-PER-JOB,CONST-BUDGET-BEFORE-TOKENS,CONST-ISSUE-TEXT-IS-DATA,DES-RUNTIME-SETTINGS-FILE-OVERLAY,DES-AI-TRIGGER-FLOW-GATE,DES-JOB-OUTBOX-CHAINING,REQ-DURABLE-RUN-HISTORY
- Decision: Subscription plan prices live in an operator-authored file,
subscriptions.json(INT-SUBSCRIPTIONS-FILE-CONTRACT), and feed counterfactual arithmetic only: the admin extension prices runs that already happened against the declared plans (what did the flat rate really cost, what would the same runs have cost at thecounterfactualModel's API rates, what would ahypotheticalplan under consideration have cost). The file never touches auth, routing, or model selection — no declared plan changes which provider a job uses, which credential it carries, or whether it runs. The worker exports the sharedparseSubscriptionsvalidator (theparseTriggers/parsePauseWindowsanti-drift idiom) and reads nothing at job time; the admin extension is the only reader. - Why:
- Zero-rate tables make prepaid look free. Subscription-backed providers (pi-ai's
kimi-coding,zai-coding-cn) ship all-zero rate tables, so every covered run records cost 0 and the spend meters report a paid-for plan as a free lunch. The declaration is what turns "cost 0" back into "prepaid at a price somebody is actually paying". - The env boundary refuses subscription logins by design.
env-allowlist.mjsrejects an OAuth/subscription credential deliberately (it expires; an unattended service cannot refresh it), so no credential that could name the plan ever reaches the worker — the operator declaration is the only honest price source, not merely the most convenient one. - Declaring a plan must never become a way to route to it. A file the admin reads for arithmetic is harmless; the same file consulted at job time would be a second model-selection channel that bypasses the overlay's precedence and the env boundary's refusal. Keeping the worker out of the file entirely makes that misuse unrepresentable rather than merely discouraged.
- Zero-rate tables make prepaid look free. Subscription-backed providers (pi-ai's
- Rejected:
- Vendor usage-API polling — a new network surface, credentials, and failure modes for numbers
vendors barely publish; the file's
null-means-undisclosed unit/limit is the honest version of the same ignorance. - Auto-detecting plans from zero-rate tables — a rate card is not a purchase; a provider whose table is zeros says nothing about whether this operator pays for it, at what price, or shared with what.
- Overlay keys instead of a file — the overlay is runtime tuning with fail-closed job-start semantics
(
DES-RUNTIME-SETTINGS-FILE-OVERLAY); prices are bookkeeping that no job start should ever refuse on, and a versioned, diffable operator file is the right trust class for a declaration. - Routing/auth integration — reopens the env-allowlist decision this design exists to respect: the refusal of subscription logins is the reason the file exists, so the file must never become the workaround for it.
- Vendor usage-API polling — a new network surface, credentials, and failure modes for numbers
vendors barely publish; the file's
- Traces to:
DES-ADMIN-VIA-PI-EXTENSION,INT-SUBSCRIPTIONS-FILE-CONTRACT,REQ-TOKEN-ACCOUNTING-AND-CAPS
- Decision: Cost aggregation is a read-only, filename-keyed scan of the run-history sidecars
(
scanRunRecordsin the admin read-model —listRuns' sibling without the 50-record clamp), bounded byPI_LOG_RETENTION_DAYSand hard-capped at 92 days even when retention is the keep-forever0, folded by a pure, fs-free module (admin/src/costs.mjs) into per-day/per-flow/per-model aggregates and plan verdicts. Classification — metered / plan / zero-rated / estimated / seeded / unknown — happens at fold time and is never stored: the sidecars hold immutable facts (what ran, what it spent, at which rates-version), and everything derived from the operator's opinions (subscriptions.json, the pinned rate tables reached through the worker's./pricingexport) is recomputed on every fold, so editing a subscription retroactively reclassifies history — correctly, because facts and opinions never share a file. Every dollar the fold emits is a typed value{ usd, class, floor, coverage }rendered only by the panel'sfmtCost; a sum staysmeteredonly when every addend is, and one estimated addend demotes the whole sum visibly. The fold'swindow.days— the denominator every plan proration scales by — comes from the requested window (sinceMs, the same instant the caller cut the scan at, minted by the onecostsSinceMsbeside the fold), never from the span the records happen to cover;firstRunMsrides beside it for renderers that want the observed left edge, and a caller folding an arbitrary record set with no window to ask about (sinceMs: null) keeps the observed-span derivation. - Why: The records are already the durable store (
DES-RUN-HISTORY-FLAT-FILES-NO-DB), retention already bounds them, and a retention-bounded directory of ≤2KB single-line files folds in milliseconds — aggregation earns no second store. Fold-time classification is what keeps the one promise the whole screen rests on: an estimate can never be mislabeled as truth, because the label is computed where the comparison is made, not persisted where it could go stale. - Rejected:
- An embedded analytics store (sqlite / lowdb) or a query layer — re-refused for exactly
DES-RUN-HISTORY-FLAT-FILES-NO-DB's reasons: a native build, a second retention authority, query power that nothing needs at this size. - Rollup / index files beside the sidecars — a second source of truth that must be invalidated on
every retention sweep, every retry overwrite, and every
subscriptions.jsonedit; the failure mode is a stale rollup silently disagreeing with the records it summarizes, and the win is milliseconds that were never being lost. - A redis cost series beside
budget:t:*— the budget keys are TTL'd enforcement state, not history; parking analytics in them couples the screen to counter TTLs and adds a write path to what is deliberately a read-only feature. - Storing the classification on the record — a record written under one subscriptions file lies under the next; the fact/opinion split is the design.
- Deriving
window.daysfrom the first observed run — shipped that way once and refuted (issue #175): on a sparse window the proration denominator shrank to the observed span (two runs early in a month-to-date question prorated a $99 plan to pocket change), so verdicts read SAVING far too easily. The observed span is a fact about the records; the denominator is a fact about the question asked. - The fold re-deriving the trigger join itself — the per-trigger rollup (issue #175) takes the
read-model's
attributeRunsToTriggersresult as an ARGUMENT (triggerJoin), the injected-pricing pattern: the index+type agreement doctrine and the rawrepeat:<id>:<millis>grammar were adversarial-review-hardened once inread-model.mjs, and a second implementation inside the fold is a fork of that doctrine waiting to drift. The fold stays fs-free and worker-import-free;byTriggeris null (not empty) when no join was wired, because "not computed" and "nothing attributed" are different sentences.
- An embedded analytics store (sqlite / lowdb) or a query layer — re-refused for exactly
- Traces to:
DES-RUN-HISTORY-FLAT-FILES-NO-DB,DES-SUBSCRIPTIONS-ARE-COUNTERFACTUAL-ONLY,INT-PRICING-EXPORT-CONTRACT,REQ-TOKEN-ACCOUNTING-AND-CAPS; implemented inadmin/src/read-model.mjs(scanRunRecords) andadmin/src/costs.mjs.
- Decision: The trigger/flow graph (issue #54) is assembled by one pure fold
(
buildGraphModel,admin/src/graph-model.mjs) over the read-model's outputs, and every edge it emits is labelled by its evidence class, drawn from a closed, test-pinned vocabulary:config— trigger → flow, from the live triggers file. Every trigger naming arun.flowgets exactly one, always — dangling, unverifiable and charset-invalid included; a target the enumeration did not find exists as askill-missing(enumerated folder) orskill-unverified(forge repo / unreachable folder) node so the edge has a visible end.observed— flow → flow, from run records only (parentJobIdjoins), folded per flow pair with its count and last occurrence. An observed edge exists because a run actually spawned another, never because one could. Same-target only; an edge that cannot be hung on exactly one enumerated folder is dropped and counted (meta.droppedObservedEdges), never guessed onto a basename that merely matches.potential— flow → flow, from a text mention of a sibling skill's name in a SKILL.md, labelledstrong(near the outbox vocabulary) andeligible(the target's ownai-trigger: allow, the exact static half of the edge). Gate-eligibility alone draws no edge: it is a node badge, because an all-pairs "could chain" fabric among allow-listed skills would bury the informative edges under a combinatorial lie.cron-rearm— the one self-edge every cron trigger carries by definition, labelled with its pattern: config, not history. Two structural prohibitions, fromOQ-009: no chain edge is ever drawn out of a forge trigger's flow (a forge job gets no/outboxmount), and no chain edge ever crosses folders (the child folder is forced to the parent's own) — the harness makes both unrepresentable, so the graph never renders either. Dangling is precise:no-skillflags only where enumeration succeeded and the path is absent at HEAD (the gate's own token); an unreachable or remote folder renders unverified, never dangling, and arun.flowfailingSKILL_NAME_REis its owncharset-invalidflag — the gate would answerdenyfor it, and deny proves nothing about existence. Orphanhood is three distinct facts, not one:orphan(no trigger, noai-trigger, no incoming mention),ai-reachable-no-trigger(deliberately chain/dispatch_run-reachable), andinjected-ai-trigger(theOQ-022silent no-op, badged loudly). Sub-skills are never orphan candidates — the gate's path template has no room for them. Every model carries the chain caps (chainDepthMax,chainMaxPerJob, same-folder-only, the record window) and every consumer must render them; the honesty counters (unattributedRuns, refusals, truncation, dropped edges) ridemetafor the same reason.
- Why: The four gaps issue #54 names are all failures of assembly, not of data — every edge already exists somewhere in triggers.json, the records, or the object store. What a graph adds is precisely the temptation to blur evidence classes: a mention rendered like an observation, a gate-eligibility fabric rendered like config, a stale index landing on today's row. So the derivation rules are the design, they live in exactly one pure function, and the negative claims ("an observed edge never comes from potential-only evidence", "an unreachable folder produces zero dangling flags", "a potential edge never carries a count") are asserted as tests, the zero-cost-day-versus-absent-day discipline applied to topology.
- Rejected:
- Declared chain topology in config — chains are agent-requested at runtime by design
(
DES-JOB-OUTBOX-CHAINING); the graph reports what happened and what could, it does not promise what will. Issue #54 refuses this explicitly. - An all-pairs "could chain" edge set from gate eligibility — with N allow-listed skills that is N×(N−1) identical arrows; eligibility becomes a badge and a mention becomes the edge, or the graph is noise.
- Treating
denyas dangling —denyconflates a missing gate opt-in, a bad sha, and a git failure; onlyno-skillmeans "absent", and a graph that flagsdenyas dangling tells an operator to delete a trigger whose skill exists. - Resolving ambiguous observed-edge targets by first match — pins real history onto the wrong folder's skills; dropped-and-counted is honest, guessed is not.
- Declared chain topology in config — chains are agent-requested at runtime by design
(
- Traces to:
DES-ADMIN-VIA-PI-EXTENSION,DES-JOB-OUTBOX-CHAINING,DES-AI-TRIGGER-FLOW-GATE,OQ-008,OQ-009,OQ-022,INT-RUN-HISTORY-FILE-CONTRACT; implemented inadmin/src/graph-model.mjs(buildGraphModel) overadmin/src/read-model.mjs's graph readers.
- Decision: Runtime-tunable settings are a flat
settings.jsonoverlay — pathPI_SETTINGS_FILE, default<OS temp>/pi-dispatch/settings.json— written atomically (tmp + rename) by the admin extension and re-read by the worker at each job start. The keys are exactlymodel,provider(non-empty strings),maxTurns,dailyCap,weeklyCap,monthlyCap(int ≥1),concurrency(int 1–10), andsoftHoldPct(int 1–99).weeklyCap/monthlyCap/softHoldPctare optional ceilings/band that default to disabled when unset (the mandatory daily cap is always the primary bound —REQ-SPEND-CAPS-MULTI-WINDOW). Resolution precedence isjob.data > overlay > env > default; producers stop baking env defaults into job data, so an unset job field falls through to the overlay rather than to a value frozen at enqueue time. - Why:
- Per-job re-read needs no watcher and no IPC. The worker already opens each job in its processor;
reading one small file there costs a
stat+ parse and removes any need forfs.watch, a pub/sub channel, or a reload signal between the extension and the worker. CONST-BUDGET-BEFORE-TOKENSis untouched. The cap is resolved at the existing check point — in the processor, before the container starts — so the overlay changes which value the cap takes, never when it is checked. The ordering that is the mechanism stays exactly where it was.- Fail-closed on a present-but-invalid file. A settings file that exists but does not parse, or
violates the key contract, refuses the job start with policy reason
settings-overlay-invalid, beforereserveBudget, so it burns no budget slot. Fail-open would fall back to env and could silently restore a higher daily cap than the operator last set — money fails closed, matching theconfig.mjsposture where a cap of0fails closed rather than meaning "unlimited". - The overlay may never carry persona or hard rules. It tunes task/config knobs only; the immutable
rules stay baked. This is the
DES-FLOWS-ARE-DATA-PERSONA-IS-CODEboundary applied to settings: mutable = task/config tuning, immutable = hard rules, and the split falls on the risk, not on the filesystem. This bar is on this admin-editable runtime channel — the one an admin-surface compromise can bend. It does not constrain deploy-time operator config: the global pi overlay (DES-OPERATOR-GLOBAL-OVERLAY) may carry a persona layer, because it is operator-authored:roconfig at the same trust level as baking, not a runtime-mutable knob. - A file, not Redis, so the live configuration is inspectable, hand-editable with an editor,
survives a Valkey flush, and does not become a second opaque state store. The shared-filesystem
assumption it relies on is true by construction:
DES-WORKER-ON-HOSTputs the worker and the admin extension on one box.
- Per-job re-read needs no watcher and no IPC. The worker already opens each job in its processor;
reading one small file there costs a
- Rejected:
- Redis-resident settings — matches the
queue.pause()precedent but is opaque, dies with a Valkey flush, and both issue #5 and the maintainer decision specify a file. fs.watch/ pub-sub hot-reload — more moving parts for a job cadence measured in minutes.concurrencyis the one key a live reload would help, and it takes effect at the next pickup anyway — a named limitation, not a defect.- Per-message env mutation — configuration is boot-only by design for identity keys (
valkeyUrl,jobImage, auth); those stay env-only and out of the overlay. Still rejected, andrun.imageis not an exception to it (DES-PER-TRIGGER-JOB-IMAGE). A trigger may name its own job image, butimageis not an overlay key,dispatch_setcannot set one, and the key list is unchanged. The two are different trust classes, and this entry already says which one it bounds: this overlay is the admin-editable runtime channel — the one an admin-surface compromise or a prompt injection in the operator's session can bend, which is exactly why the "may never carry persona or hard rules" bar above is scoped to it and explicitly not to deploy-time operator config.triggers.jsonis the other kind: operator-authored, reviewed, diffable, git-trackable, in the trust classREQ-GLOBAL-PI-OVERLAYnames as "operator deploy-time config — the same trust class as baking the image". Naming an image there is literally that act, per flow instead of per deployment.PI_JOB_IMAGEsurvives unchanged as the deployment default and stays env-only. What moved is that a reviewed file may override it per trigger; not that a runtime knob may.
- Redis-resident settings — matches the
- Traces to:
CONST-BUDGET-BEFORE-TOKENS,DES-ADMIN-VIA-PI-EXTENSION,DES-FLOWS-ARE-DATA-PERSONA-IS-CODE,DES-WORKER-ON-HOST
- Decision: A flow is AI-triggerable only if its
.pi/skills/<flow>/SKILL.mdYAML frontmatter carriesai-trigger: allow, read from the git object store at the SHA the job was prepared from — the SHA captured before the in-container agent runs, and never any commit the agent authors during its run. Default deny: absent frontmatter, an absent key, any other value, or a flowless AI trigger is refused. This gate governs both AI-triggered producers — the admindispatch_runtool/command (DES-ADMIN-VIA-PI-EXTENSION) and the worker's outbox collector (DES-JOB-OUTBOX-CHAINING). The operator-typed CLI (DES-CLI-TRIGGER-FOR-LOCAL) is not model-callable and is not gated by this opt-in. - Why:
- The SHA is pre-agent and agent-uninfluenceable — this is the load-bearing property. The opt-in is
read from the object store at the commit the job was prepared from, fixed before the agent starts, so
an agent cannot self-authorize by committing its own
SKILL.md: anyai-trigger: allowthe agent writes lands in a commit later than the pinned SHA and is never consulted for that job. Reading committed, reviewed content at a pinned SHA rather than the working tree is the same trust doctrine asCONST-NO-CONTEXT-FILES-MANDATORY(which, as amended, admits merge-gated repo files precisely because they are merge-gated — and still reads them at a fixed SHA, never from the live tree) andDES-PERSONA-VIA-APPEND-SYSTEM-MD(the persona is baked, not taken from the working tree), applied one layer down to the trigger opt-in. Object-store reads are also symlink-safe: reading the blob by object id (git cat-file blob <oid>, blobs only, mode100644) mirrors theworker/src/materialize.mjsblob-only discipline, so anai-triggerfrontmatter symlinked at a token file cannot escape the tree. - Author-controlled and versioned. The opt-in lives in the project's committed
.pi/, reviewed like everything else there. Making a flow AI-triggerable is a reviewed commit, not a runtime toggle — the same reviewability that keeps flows as reviewed repo markdown (DES-FLOWS-ARE-DATA-PERSONA-IS-CODE). - Relationship to
CONST-TRIGGER-AUTHOR-GATE, stated carefully. That constraint is webhook/comment-scoped and governs WHO may start a job (on GitHub, only a collaborator can apply the allowlisted label; on GitLab that premise is false and the actor's resolved access level is the gate instead —CONST-TRIGGER-AUTHOR-GATE). It is unaffected here. The frontmatter opt-in governs WHICH flows a model-callable tool may fire — a different axis, WHAT not WHO. It is an additional local defense, justified because thedispatch_runtool and the outbox collector are prompt-injection-reachable where the operator-typed CLI is not. It does not "satisfy" or "extend" the author-gate — treating a WHAT-gate as a WHO-gate would be a category error; the two are orthogonal axes and both hold. - Default-deny is fail-closed. An unreadable, absent, or malformed opt-in refuses the trigger rather
than admitting it. A flow becomes AI-triggerable only by an explicit, committed, reviewed
allow.
- The SHA is pre-agent and agent-uninfluenceable — this is the load-bearing property. The opt-in is
read from the object store at the commit the job was prepared from, fixed before the agent starts, so
an agent cannot self-authorize by committing its own
- Named residuals:
- The enqueue→run TOCTOU window. The frontmatter is read at prepare time; a later flip between
enqueue and run is not re-checked for the in-flight job. The window is bounded by the daily cap
(
CONST-BUDGET-BEFORE-TOKENS) and by both producers being host-trusted — the operator (dispatch_run) or the worker (outbox), not the adversarial container. - A local agent can commit
ai-trigger: allow. An agent that can write a folder can commit the opt-in to it, after which a later operator or CLI action could run that flow. This is bounded by the local trust model — "whatever can write the folder can trigger it" (SECURITY.md) — and is not a self-authorization within the same job, which the pre-agent SHA forecloses.
- The enqueue→run TOCTOU window. The frontmatter is read at prepare time; a later flip between
enqueue and run is not re-checked for the in-flight job. The window is bounded by the daily cap
(
- Rejected: reading the working tree rather than the object store — it reintroduces exactly the two
holes the pinned-SHA read closes: an agent self-opening the gate by writing
SKILL.mdmid-run, and a symlink bypass that a blob-only object-store read cannot follow. - Injected skills are trigger-reachable and NEVER AI-reachable, and that falls out rather than being
built (
REQ-PER-TRIGGER-SKILLS, issue #60). This gate reads.pi/skills/<flow>/SKILL.mdfrom the serviced repo's git OBJECT STORE at a pre-agent sha. A skill injected from the worker host has no object-store presence at all, so the read finds nothing, the gate returnsno-skill, and both callers refuse. No new code, and the fail-closed direction is the right one: an operator's own reviewedtriggers.jsonentry is the authorization for a TRIGGER to run an injected flow, and that is a different question from which flows a MODEL may fire. The corollary is the part an operator cannot discover unaided, so it is stated rather than left implicit: an injectedSKILL.mdcarryingai-trigger: allowis never read, and writing one is a silent no-op.doctorwarns when it finds one. Making injected skills AI-reachable was considered and refused for v1: the opt-in would live in a tree the operator can edit at runtime, outside the merge gate that makes the frontmatter meaningful, so the right shape would be an operator allowlist rather than frontmatter — the same reasoningREQ-GLOBAL-PI-OVERLAYgives for refusing repo-declared packages. ResidualOQ-022. - Traces to:
CONST-TRIGGER-AUTHOR-GATE,CONST-NO-CONTEXT-FILES-MANDATORY,CONST-BUDGET-BEFORE-TOKENS,DES-ADMIN-VIA-PI-EXTENSION
- Decision (issue #189): whether a trigger's
run.flowactually resolves to a skill is verified at two advisory layers, and refused at neither. The runner is the exact layer: the worker forwards the flow name structurally (PI_FLOW,INT-CONTAINER-JOB-INPUTS), and after the resource loader builds — before any session or spend — the runner compares it against the loaded skill names and emits oneflow_not_loadedline on a miss (isFlowLoaded, exact name equality; adisableModelInvocationskill counts, it is invocable even though uncatalogued). Doctor is the approximate host-side layer: one line per distinct (flow, folder, skillsDir, packages) question, probing the tiers in the loader's own precedence order — repo.pi/skillsat HEAD (the gate's ls-tree read and 100644-blob rule, but HEAD-resolved by doctor itself and degrading to "unknown" on git failure, because the gate's fail-closed catch would print a confident wrong answer on an advisory line, and its no-ref rule defends against an agent that a host-side preflight does not have), injectedrun.skillsDir, overlayskills/, then staged packages viareadStagedSkills(pi's manifest-vs-convention rule at the pin; glob/override manifests make a package "not enumerable" rather than guessed at, because patterns can also DISABLE files and a wrong ✓ is the one direction an advisory may not err in). ⚠ never ✗, no fix action, zero triggers add zero lines; a staged-package-only ✓ is deliberate (legal steady state). The job itself proceeds. - Why: the flow reaches the model only as prompt prose (
Use the "X" skill), and pi never matches prose against loaded skill names — so a flow that materialised in no tier ran to a clean exit 0 without the procedure it was written for, reporting success for work it could not have done. That is this project's branded worst outcome ("a silent no-op"), already refused pre-spend for its sibling (an unmounted package root,assertPackagePathsExist). The runner layer is exact where every host-side answer is an approximation: pi names a skillfrontmatter.name || parentDirNameat the pin, so only the loaded set is authoritative — which is also why the check needsPI_FLOWrather than re-deriving anything, and whygetSkills()is read unconditionally rather than only for packaged jobs. - Why report rather than refuse, deliberately:
run.flowis by long doctrine a prompt hint (prepare.mjs), and deployments legitimately run flows as loose hints over repos with no.pi/skills— the runner cannot distinguish that steady state from breakage, and a refusal shipped in an image upgrade would fail yesterday's jobs for a value the reviewed file has carried all along (it would also burn the reserved budget slot per refusal, paying for zero work on every delivery of a misconfigured trigger). The check sits at the pre-spend moment anyway, so flipping report to refusal is a one-line change plus a row here. The residual is stated: an advisory line at 03:00 helps only an operator who reads logs; doctor's per-trigger lines are the layer that reaches them earlier. - Rejected: failing worker/receiver boot on an unresolved flow (
parseTriggersis pure and fs-free byDES-TRIGGERS-UNIFIED-FILE, the receiver may run on a host with no repo, and overlay and package tiers make "absent at HEAD" a legal steady state); carrying the flow inevent.json(an execution knob is not a fact about the delivery —run.replicasprecedent); a new top-level outcome for the miss (admin surfaces bucket outcomes into a closed set and silently drop unknowns, so new vocabulary must ride areason, and an advisory line needs neither). - Traces to:
INT-CONTAINER-JOB-INPUTS,REQ-PER-TRIGGER-SKILLS,REQ-GLOBAL-PI-OVERLAY,DES-AI-TRIGGER-FLOW-GATE(a WHAT-exists question, deliberately distinct from its WHO-may-fire gate),CONST-BUDGET-BEFORE-TOKENS
- Decision: An agent inside a local job requests follow-up flows by writing
request-<n>.jsonto a read-write/outboxmount. The worker is the only enqueuer: it collects/outboxonly after a completed container exit and enqueues ordinary local jobs (enqueueLocalJob, the same producer path as the CLI). The container never enqueues and never learns the queue exists. - Why:
- Containers stay queue-blind. No
VALKEY_URL— or any queue credential — ever crosses the container boundary (CONST-ISOLATION-CONTAINER-PER-JOBpreserved). The/outboxfile is the container's only signal channel back to the host; being agent-authored it is untrusted and is validated host-side before anything is enqueued. - The outbox host dir is NOT under
/workspace. It is a separate per-job mount, so agent-authored task text is never swept into the operator's folder, committed, or pushed to a PR branch — the same propertyno-token-in-agent-reachable-fileprotects for credentials, here keeping the operator's tree clear of agent-authored request data. - Completed-only collection.
/outboxis read only on a completed container exit. A policy parent — the agent concluded "can't", a worker-side abort, or an over-budget refusal — spawns no paid follow-ups, and an infra-thrown parent is retried (CONST-RETRY-INFRA-ONLY); collecting at any other point would double-chain across attempts. - Control-vs-data split. Structured fields are allowlist-validated: the flow name is checked
against the skill charset and the frontmatter gate (
DES-AI-TRIGGER-FLOW-GATE), and the child folder is forced to the parent's own folder — the outboxfolderfield is ignored — so this slice is same-folder-only, with no arbitrary host-path mount. The freeform task text is DATA: it lands in the child'sprompt.md, never as instructions to the harness (CONST-ISSUE-TEXT-IS-DATA, one layer down — the same payload-subset discipline the receiver applies to issue text). - Forge-parent outboxes are dropped. A forge job is driven by adversarial issue text, so no
/outboxmount is created for any forge kind (github,gitlab) — an untrusted issue author cannot chain. This is a deliberate deferral, recorded inline here; the open-questions register row is a sibling task's job. - Budget is unchanged. Chained jobs are ordinary local jobs; they pass
reserveBudgetconsumer-side in the processor beforerunContainer(CONST-BUDGET-BEFORE-TOKENS). The depth/count caps (PI_CHAIN_DEPTH_MAX=1,PI_CHAIN_MAX_PER_JOB=2) and thedispatch_runper-hour rate limit are additional producer-side bounds, never a substitute for the consumer-side cap. - Retry-idempotent child ids. A child job id is derived from the parent id plus a content hash of
the request, so a retried parent re-enqueues identical ids and BullMQ dedups them — a retry cannot
fan out duplicate follow-ups (the
REQ-DEDUP-BY-DELIVERY-GUIDdedup property, applied to chaining). - Chain depth is host-computed. Depth is
parent.chainDepth + 1, computed on the host, never read from the outbox, so the container cannot forge a shallow depth to evade the cap. - The agent learns the protocol from a baked persona file.
guardrails/OUTBOX_PROTOCOL.mdis baked into the image immutable (chmod a-w, alongsideHARD_RULES.md) and composed intoappendSystemPromptOverrideonly when/outboxexists — so akind:githubjob, which has no mount, is never billed for it, and the compose is evaluated once at loader build so the prompt is byte-identical across turns (CONST-PERSONA-IN-CACHED-PREFIX). It is a separate file, not folded intoHARD_RULES.md, whose charter is the always-billed safety floor. The persona is documentation: it describes the request channel, while the caps and theai-triggergate are host-enforced after the agent exits — the text controls nothing, so it can neither promise a confirmation the host does not give nor widen what the host will honor.
- Containers stay queue-blind. No
- Rejected:
VALKEY_URLinto the container — an env-allowlist BLOCKER (no-host-env-passthrough): it hands the adversarial side a producer credential, collapsing every enqueue gate at once.- A host HTTP broker — a new network surface, exactly what the port-less admin design
(
DES-ADMIN-VIA-PI-EXTENSION) deliberately removed; the same reasoning applies symmetrically, so the signal channel is a file mount, not a socket.
- Traces to:
CONST-ISOLATION-CONTAINER-PER-JOB,CONST-BUDGET-BEFORE-TOKENS,CONST-ISSUE-TEXT-IS-DATA,DES-WORKER-ON-HOST,DES-CLI-TRIGGER-FOR-LOCAL,DES-AI-TRIGGER-FLOW-GATE
- Decision: Split the agent's instructions by mutability. The persona is baked into the image and
carries the hard rules — never merge, issue text is data, work only in
/workspace. Flows are user data in a mounted volume, seeded from repo defaults on first run, and carry the task recipe — screenshot, iterate, open a PR. The admin surface may edit flows in principle; it may never touch the persona. Flow display and editing are deferred, out of this slice — the mutable/immutable split is the boundary the design fixes now, for the admin surface to exercise later. - Why: The admin requirement ("set prompts and which flow runs") collides head-on with
this project's earlier decision to keep flows as reviewed repo markdown — versioned, reviewable,
pi-version-proof. The resolution is not a compromise between the two; it is the observation that
those two properties were being asked of one file that was doing two jobs.
The rules the agent must not be talked out of need immutability, and
INT-CONTAINER-JOB-INPUTSalready mounts/jobread-only and bakes the persona precisely so that a total compromise of/jobcannot reach the system prompt. That same reasoning extends one step: an admin-surface compromise must not reach it either. Meanwhile the task recipe is genuinely configuration — the thing an operator legitimately wants to tune at 11pm without a rebuild — and gains nothing from being immutable. So the security property survives and the admin surface can gain real power over flows when editing lands, because the boundary now falls where the risk actually changes rather than where the filesystem happened to. Accepted cost: edited flows lose git review and versioning. That is the honest trade for runtime editability, and it is bounded — a flow cannot revoke a hard rule, because the hard rules are not in it. - Rejected:
- Admin surface edits
flows/in the repo — two sources of truth between a git checkout and a running system, and the classic "why did my change vanish on redeploy". - Everything baked, admin surface read-only — satisfies the specs and not the user; a surface that cannot change anything is a dashboard.
- Everything admin-editable including hard rules — makes
CONST-MERGE-NEVER-AUTOMATICandCONST-ISSUE-TEXT-IS-DATAruntime-mutable state. They are constitutional precisely because they are not negotiable at runtime.
- Admin surface edits
- Clarification (operator deploy-time overlay): "The admin surface may never touch the persona" governs
the admin-editable runtime channel — the settings overlay (
DES-RUNTIME-SETTINGS-FILE-OVERLAY) — which an attacker who reaches the admin surface could bend. It does not bar the operator from supplying a persona at deploy time. The global pi overlay (DES-OPERATOR-GLOBAL-OVERLAY) is operator-authored,:ro-mounted deploy-time config — the same trust class as baking~/.pi/agent/APPEND_SYSTEM.mdinto the image — and may carry a persona layer under the immutable floor. Mutability, not the persona/flow label, is the boundary: the bakedHARD_RULES.mdstays first and unremovable regardless. - Traces to:
CONST-ISSUE-TEXT-IS-DATA,CONST-MERGE-NEVER-AUTOMATIC,INT-CONTAINER-JOB-INPUTS,DES-PERSONA-VIA-APPEND-SYSTEM-MD,DES-OPERATOR-GLOBAL-OVERLAY,DES-PANEL-SEPARATE-FROM-RECEIVER
- Decision: Meter token usage process-wide, at pi-ai's module-level api-provider registry — not
on an
AgentSession's event bus. The runner wraps every registered api id with a{ streamSimple }that dispatches exactly as compat would and then observes the returned stream (stream.result()is a memoised promise resolved from the terminal event, so awaiting it accounts for a call without consuming it; the stream object is returned untouched, no proxy, so identity andinstanceofstill work downstream). Registration goes throughmodelRegistry.registerProvider, soModelRegistry.refresh()re-applies it, plus an unref'd re-arm interval and a deterministicarm()aftercreateAgentSession.options.sessionIdgives the root/other attribution for free. Thesubscribe()per-turn accumulator (attachTokenBudget) survives as the fallback, attached only when the meter could not install, so exactly one accumulator is ever live. - Why: The bus is per instance and no event carries a session id, so a subagent session an extension
spawns is invisible to it — a 16-wide fanout registers as roughly one turn, and both the cap and the
run record then understate spend on exactly the most expensive jobs. The registry is the one choke point
every in-process session funnels through: pi-coding-agent's session calls compat's
streamSimple, compat resolves the provider formodel.apiout of that registry, and root and subagent alike pass through it. Metering there counts calls rather than turns, which is the honest unit anyway — a turn is a bundle of calls whose count we do not control. Two properties fall out for free and are worth naming: per-session attribution (sootherTotal > 0is the evidence of subagent spend), and a forward brake that the bus could never give —session.abort()is voluntary and does not propagate to a child, whereas after a breach every subsequent call by any session is answered with a synthetic aborted stream before it reaches a provider. The cap stays structurally lagging (OQ-010) either way;REQ-JOB-TIMEOUT-30Mis still the ultimate backstop. - Rejected:
- Keep the
subscribe()-only meter — the mechanism this replaces. It is correct about the session it subscribed to and blind to every other one, which is the whole defect; it stays as the fallback so a job still gets totals when the registry cannot be reached. session.getSessionStats()— the cumulative as-billed total is session-scoped, so it has exactly the blind spot the bus has, with the added cost of being a poll rather than a hook.- Parse the provider SSE stream (an
undiciinterceptor or a fetch shim) — would reimplement usage extraction for ~30 provider wire formats, break silently whenever one changes a field name, and be wrong by construction for any provider that does not go through the intercepted transport. It is also the exact reinventionno-reimplementing-piforbids: pi already parses usage and hands it to us. - An
after_provider_responseextension hook — an extension handler is registered on anAgentSession's own extension runtime, so it inherits the same per-instance scope that disqualifies the bus, and it would place the harness's accounting inside the untrusted extension surface the meter exists to watch. - Patch or vendor pi — a monkey-patch of
dist/turnsCONST-PI-VERSION-PINNED's "upgrading is one version string" into "upgrading is a fork". The registry is a supported, exported seam; use it.
- Keep the
- Must handle (each verified by runtime probe, none by reading source — this is the part that bites):
- Two module instances. pi-ai is installed twice (hoisted, and nested under pi-coding-agent) with
separate module-level registries, and pi-coding-agent uses the nested one. A bare-specifier import
from runner code binds the hoisted copy and is a silent no-op — it registers, reports success, and
counts nothing — and
import.meta.resolvereports the wrong path convincingly. Acceptance is decided only by a mutation probe: register an inert provider through theModelRegistry, then ask the candidate module whether it can see it. The probe is never unregistered, becauseunregisterProvider→refresh()→resetApiProviders()would wipe every wrapper. resetApiProviders()wipes the registry. It is whatAgentSession.reload()calls, so the meter cannot be install-once. Registering through theModelRegistrycovers therefresh()path (it re-applies stored configs); the unref'd interval covers the bare-reset path, which re-applies nothing. That leaves a re-arm gap — a call landing between a wipe and the next poll is unmetered, and the only symptom is a total that reads like a cheap job — so the count of displaced api ids, the number armed, and the poll interval are reported at teardown, which is the only evidence such a window existed.- Displacement, in both directions. An extension may register its own provider for an api id after we
armed, and
refresh()re-applies our stored config as a fresh object — so a wrapper chain can form thatarm()cannot tell from a third party's override. Wrapped entries are therefore tracked by object identity, one provider name per api id (so a re-arm upserts rather than piles up), and every observed stream is remembered in aWeakSet— every link of such a chain hands us the same stream object, which makes a double count impossible rather than merely unlikely. - Builtin-auth fidelity. Overriding a builtin api id flips compat's
shouldUseBuiltinModelsto false, so compat stops consulting its own model catalog and calls us instead. The wrapper therefore reproduces that branch against the catalog loaded as a sibling of the accepted compat module (never by specifier — that would reopen the two-instance trap): 2 of the 35 builtin providers substitute baseUrl placeholders and inject headers in that layer, and bypassing it breaks exactly those. If the catalog cannot be loaded the meter degrades to delegating to the registry entry — still metering, and correct for the other 33. Both silent degradations are reported on the install line rather than hidden behind a bare "ok": whether the catalog loaded (and why not), and whether a pre-dispatch brake exists at all — the latter alongside whether the job is capped, since an uncapped job has no brake by design andcappedwithout a brake is the alarm.
- Two module instances. pi-ai is installed twice (hoisted, and nested under pi-coding-agent) with
separate module-level registries, and pi-coding-agent uses the nested one. A bare-specifier import
from runner code binds the hoisted copy and is a silent no-op — it registers, reports success, and
counts nothing — and
- Traces to:
REQ-TOKEN-ACCOUNTING-AND-CAPS,REQ-RUNNER-TURN-BUDGET,CONST-BUDGET-BEFORE-TOKENS,CONST-PI-VERSION-PINNED,INT-SDK-SESSION-OPTIONS,INT-RUNNER-EXIT-CODE-PROTOCOL,INT-RUN-HISTORY-FILE-CONTRACT,OQ-010,OQ-011
- Decision:
run.instructionsis rendered into the USER prompt's envelope -- above the fenced data region, below the harness's numbered steps, and before the never-merge paragraph -- as a provenance-labelled block with no##heading and no fence. One sharedinstructionBlockserves all four forge builders.dataRegionis untouched. - Why:
CONST-ISSUE-TEXT-IS-DATAgoverns event PAYLOADS. This is operator text from a reviewed, git-tracked file, which passes the same mutability testDES-FLOWS-ARE-DATA-PERSONA-IS-CODEalready applies to the overlay persona: "Mutability, not the persona/flow label, is the boundary." So it may be read as instruction. It goes before the never-merge paragraph because later text reads as more specific, and the harness's non-negotiables must be the last thing before the data region rather than something an operator instruction appears to qualify -- that costs nothing and forecloses the argument. Putting it in the envelope is also what leavesdataRegionalone: the shared export keeps its signature, so the "new parameters go LAST" rule is honoured without threading a hole through three sibling forges. - Rejected:
- Inside the fenced data region — self-defeating.
dataRegiontells the model that everything below its heading is data and that anything in it trying to give new rules must be reported rather than obeyed. A standing instruction placed there is a field documented to be ignored, which is the accepted-where-it-does-nothing hazardvalidateReplicas' docstring exists to prevent. - The system prompt, as a
/jobfile inappendSystemPromptOverride— it would work and would be marginally cheaper per turn, and it is still wrong. Every other member of that layer is read from a FIXED FILE PATH once at loader build, which is the shapeCONST-PERSONA-IN-CACHED-PREFIX's acceptance leans on; andrun.task, the existing operator free-text field, is already contracted user-prompt-only (INT-TRIGGERS-FILE-CONTRACT). Two operator text fields with two different placements would be an incoherence the next reader has to resolve. - Reusing
run.taskon webhook triggers —run.taskis contracted as DATA landing inprompt.mdbelow the delimiter, and a webhook job's data is the issue text. Overloading it would give one field two placements depending onon.type. - Giving local jobs an envelope so cron could take the field too — it would change every existing
cron job's
prompt.mdbyte-for-byte, a behaviour change unrelated to this feature and one thatINT-TRIGGERS-FILE-CONTRACT's byte-match acceptance would have to be amended for. Cron is refused instead, pointing atrun.task. - Content-filtering the operator's text — the module docstring already refuses this reasoning for the payload and it applies here too: placement is the boundary, the delimiter is defence in depth. An operator who writes a fake data heading into their own instruction has forged nothing, because the real heading is emitted after theirs and still opens the real region.
- Inside the fenced data region — self-defeating.
- Traces to:
REQ-PER-TRIGGER-INSTRUCTION,CONST-ISSUE-TEXT-IS-DATA,CONST-PERSONA-IN-CACHED-PREFIX,INT-CONTAINER-JOB-INPUTS,DES-FLOWS-ARE-DATA-PERSONA-IS-CODE
- Decision: A trigger's
run.skillsDiris copied, per job, into<jobDir>/trigger-skills, and reaches the container at/job/trigger-skillson the/job:robind that already exists. No mount is added. The copier isworker/src/copy-tree.mjs, shared withimport-pi. Precedence is repo > injectedoverlay, in
additionalSkillPathsand again inskillsOverride's protected roots. - Why: Three reasons, and the mount-count one is the weakest of them.
The copy is the pin.
:robounds the CONTAINER, not the host. pi reads a skill's body on demand through the read tool, so under a live bind an operator editing their skills directory would change the instructions of a job already running. Copying gives the injected tier exactly the propertyINT-CONTAINER-JOB-INPUTScites for materialising.pi/rather than letting pi discover it: the agent cannot be handed a moving target. Symlinks get answered once, on the side that can answer them.loadSkillsFromDirInternalfollows both file and directory symlinks. Under a mount, a directory symlink pointing at/would turn skill discovery into a walk of the container filesystem, and one pointing into/workspacewould alias repo-controlled content into the operator-trusted tier. The host-side copier refuses links outright, so the tree pi walks contains none. And it adds no mount.CONST-ISOLATION-CONTAINER-PER-JOB's acceptance ENUMERATES the mounts, and this entry's sibling already refused a mount for staged packages on exactly that trade. A per-trigger mount would be a worse case than the one the 2026-07-31/sessionrow argues for:/sessionis at least worker-created and per-job, whereas this source is operator-named and shared across every job of the trigger. A fourth, smaller:retainJobDirrenames the whole job dir andbuildSandboxRunArgsre-mounts it, so a resurrected sandbox sees the skills the run actually saw, rather than re-reading a host directory that may have changed since. - Rejected:
- A per-trigger
:robind of the operator's directory — the shape the issue originally sketched. It costs an amendment to a constitutional enumeration for zero capability the copy lacks, and it is weaker on three counts (the source can change under a running agent, the tree pi walks keeps whatever symlinks the operator's tree has, and a resurrected sandbox re-mounts a moving target). The one thing it buys, no per-job copy cost, is bought back by the caps. Honestly qualified: a bind's source path is also legible from inside the container via/proc/self/mountinfo, which is host-layout disclosure the design otherwise avoids — an increment rather than a new class, since/joband/workspacealready bind host paths. - Copy into the existing
/job/pi/skills— it would make repo skills and injected skills indistinguishable, so precedence between them would be decided by copy order rather than a stated rule, and it would put host-filesystem bytes into the tree whose acceptance promises "no host file content anywhere in/job", making that clause ambiguous exactly where it must not be. - A host-side cache or hardlink farm shared across jobs — cross-job host state is what
CONST-ISOLATION-CONTAINER-PER-JOB's "none host-wide" clause excludes, and a hardlink is the same inode the operator can rewrite mid-run, which forfeits the pin the copy exists to provide. - An env var telling the runner where the injected root is — a second source of truth that can
disagree with the filesystem, silently and in the expensive direction (a variable set, a directory
that did not land, a job running without the skills its flow was written for). The worker creates the
directory only when the trigger set the field, so PRESENCE is the signal and
existsSyncis the whole detection.CONST-NO-CONTEXT-FILES-MANDATORY's "no env knob for this, deliberately" is the same instinct, andPI_PACKAGESis not a counter-example: package paths are operator-chosen and variable in count, so they must be told; this root is a fixed constant whose only variable is existence.
- A per-trigger
- Traces to:
REQ-PER-TRIGGER-SKILLS,INT-CONTAINER-JOB-INPUTS,INT-TRIGGERS-FILE-CONTRACT,CONST-ISOLATION-CONTAINER-PER-JOB,DES-OPERATOR-GLOBAL-OVERLAY
- Decision: Reuse an operator's existing host
pisetup across every job through a single global overlay dir (PI_GLOBAL_PI_DIR), bind-mounted/opt/pi-global:rointo each container and layered as a new trust tier 2 between the baked floor and the per-repo.pi/. It supplies custom models (models.json), global skills, and a global persona; the runner reads them (models path selection,additionalSkillPathswith the repo path first, a global persona entry inappendSystemPromptOverride). A host-sidepi-dispatch import-pistages the credential-free subset of~/.pi/agentanddoctorre-verifies it. Extensions are staged and loaded by default —--no-extensionsis the escape hatch, every staged extension is printed by name, andPI_GLOBAL_ALLOW_EXTENSIONSsurvives as an opt-OUT ("0") — with the admin extension hard-blocked. A runtime mount, not a rebuild, so it works with the pulled image. The overlay carries a fourth thing: operator-staged pi packages atpackages/<dir>/, installed on the host byimport-pi --with-packagesfrom an exact-pinnedpi-packages.jsonand handed to the runner asPI_PACKAGES— absolute container paths, appended last toadditionalExtensionPaths, for every job except one whose trigger setrun.packages: false. - Why: Four trust tiers, each refining but never removing the one above — baked floor (immutable) →
operator overlay (deploy-time, operator-authored) → per-repo
.pi/(trusted-by-merge) → adversarial input. The overlay sits at the operator's own trust level, which is why it may carry a persona (unlike the admin-editable settings overlay) yet must stay:roand credential-free (it rides into an adversarial-input container:CONST-TOKEN-SCOPED-PER-JOB). Skills are first-path-wins in pi, so listing the repo path first makes repo skills override global ones — the "project refines global" semantics operators expect. The packages tier is a fifth tier inside tier 2, and it carries gates the overlay's own extensions do not. Overlay extensions are the operator's own code, vetted by having been run in their~/.pi/agentand staged from a printed list, so they load by default andPI_GLOBAL_ALLOW_EXTENSIONS=0is the opt-out. A staged package is someone else's code, so it adds an exact version pin at declaration time, an all-or-nothing host-side stage, runner-side path validation, and a per-triggerrun.packagesswitch that no env flag can express — a deployment can run one flow without a package while every other flow has it. That per-trigger switch is finer-grained than any env flag on purpose: the decision belongs to the capability-consumer, not to the host. It defaults open for the same reason the overlay's extensions do — the operator pinned and staged the thing deliberately — which makes it a withdrawal rather than an arming, and leaves the pin, the stage and the runner's refusal as the gates that still refuse by default. Staging happens on the host because pi resolves a non-npm:/git:spec as a local path, in place, with no install, no network and no writes — which is the only shape that loads underPI_OFFLINE=1inside a container with adversarial input. The finding, and where it is actually fixed: on the raw load a staged skill beats the repo's. pi buildsskillPathsasmergePaths(cliEnabledSkills, additionalSkillPaths)— the package-contributed paths first, ours after — andloadSkillsis first-path-wins, so a package'sdeployis the one pi keeps and the repo's is dropped to a{type:"collision"}diagnostic nothing reads. That would invertREQ-GLOBAL-PI-OVERLAY's documented "repo wins on conflict", and path order cannot fix it:additionalSkillPathscannot be placed ahead of the package paths. (Ordering does work for extensions, which is why the package paths are listed last there.) The ordering is pi's; the result is ours.DefaultResourceLoaderOptions.skillsOverrideis a declared option on the pinned loader, invoked with{skills, diagnostics}the momentloadSkillsreturns and before the loader stores anything — so precedence is re-imposed on the result: any kept skill under a package root whose name also exists under/job/pi/skillsor/opt/pi-global/skillsis replaced by the protected one, protected roots consulted in order so the repo still beats the overlay. The substitute is produced by pi's own publicloadSkillsFromDirwithsource: "path"— the loader thatloadSkillswould itself have used — rather than by parsingSKILL.mdhere, which would be a second, divergent reader of a format we do not own. This is not a workaround for a missing lever; it is the lever, and using it is what makes the requirement true rather than merely asserted. So a name collision is reported, not refused. An earlier draft of this entry refused the job on the grounds that there was no reordering lever; that premise was false, and refusing a conflict we have already resolved the documented way would have cost an operator a run for nothing. What survives is the visibility half: pi's unmodified collision diagnostic is read after load and logged (the winning root, never a file path), because a package whose flow was written against a procedure that is not the one now running may quietly do less than it claims — and the operator should learn that from a log line rather than from behaviour. That detector doubles as the tripwire on the pin: a future pi that reordersskillPathsso the repo already wins makes it go quiet at exactly the moment the override becomes a no-op. - Rejected:
- Copy
~/.piwholesale — dragsauth.jsonand MCP-credentialed extensions into the box. The curated subset is the point: what reaches a job should be a list someone chose andimport-piprinted, not whatever accumulated in an operator's home directory. - Bake the overlay into the image — a per-operator image defeats the pulled prebuilt image; the mount delivers the same content without a rebuild.
- Keep overlay extensions dormant until a second env flag arms them — superseded; this is what
shipped first and it was the wrong default. The arming flag was a third gate behind two the operator
had already passed (running the code in their own
~/.pi/agent, then staging it from a listimport-piprints), and its failure mode was silent in the expensive direction: an overlay present but dormant is a deployment quietly missing the setup its flows were written against, with no error to read. It survives inverted, asPI_GLOBAL_ALLOW_EXTENSIONS=0. The admin extension must still never be among them, and that is enforced by a refusal at stage time rather than by a default. - A separate
/opt/pi-packages:romount for staged packages — it would buy nothing the overlay does not already carry, and it would cost an amendment toCONST-ISOLATION-CONTAINER-PER-JOB, whose acceptance enumerates the mounts a job may see. Widening a constitutional enumeration for zero new capability is the wrong trade;packages/rides the mount that already exists, and the mount list is unchanged. - A third env flag (
PI_GLOBAL_ALLOW_PACKAGES) for them — redundant and coarser than what ships. Three gates already refuse by default between an npm package and a job (the exact pin, the all-or-nothing host stage, the runner's pre-spend path check), and the per-triggerrun.packagesswitch is finer than any env flag could be: an env flag decides for the whole deployment, which is precisely the granularity a third-party-code switch should not have. - Route packages through pi's own
settings.packages— that is the supported path for an interactive pi, and taking it would mean giving the runner aSettingsManagerthat reads a project file. The runner usesSettingsManager.inMemory()deliberately, so a serviced project's.pi/settings.jsoncan never override our spend controls (INT-SDK-SESSION-OPTIONS); re-opening that to carry a package list would trade a real protection for a cosmetic one. npm:sources resolved in-container — a live network install of third-party code, at agent runtime, inside an adversarial-input container, on every run, into a writable~/.pi/agent.PI_OFFLINE=1exists to make that branch unreachable rather than merely unused.
- Copy
- Discovery of the host's own packages (issue #102), and the four calls it turned on:
- Read pi's
settings.json, do not walk<agentDir>/npm/node_modules. pi installs with plain npm and default hoisting, so in that tree an installed package and a transitive dependency are indistinguishable; a walk would stage third-party code into every job container because it happened to be hoisted next to something the operator did ask for. Settings carries intent, git sources and enablement. The one thing it lacks is a trustworthy version — it stores the spec verbatim, which may be a range — so the pin is read off the installedpackage.json, the same field pi itself reads. We capture a version, never inherit one, which is howCONST-PI-VERSION-PINNEDsurvives a road the operator did not type. When settings is absent or malformed, discovery yields nothing and explicitly does not fall back to the walk: inferring intent from a hoisted tree would be worst exactly when the operator's config is already broken. - A package with no
pikey but a convention dir IS a pi package. The issue proposed requiring thepikey. At the 0.80.7 pincollectPackageResourcesfalls through toextensions/ skills/ prompts/ themes/when the manifest is absent, so requiring the key would have silently dropped a legitimate class. The stager already had the right predicate; discovery reuses it rather than restating it. - On by default inside
--with-packages, rather than an opt-in flag for one release. A flaglessimport-pistill stages no packages, before and after, so the only run whose behaviour moves is one that already asked for "the packages" and until now silently got a subset excluding exactly whatpi installhad put there. An opt-in release was rejected for the reason this entry already records for extensions: an overlay that is present but dormant is a deployment silently missing the setup its flows were written against, and it costs two behaviour changes instead of one. What makes that defensible is that the printed list now carries provenance per entry, and thatdoctor --fix's restage offer was narrowed to--no-host-packagesin the same change — the one automated path stays a repair, so importing is always something the operator typed. - All-or-nothing scoped to the declared set. A declared pin that fails still refuses everything. A discovered one is dropped with a printed reason, because discovery multiplies the entry count from two pins to twenty and one bad host package must not zero an overlay that was working. This does not weaken the original rule, which was about silence: a named drop is not a silent skip.
- Rejected here too: reproducing pi's enablement state for skills, prompts and themes. Only
extensions/is the sharp edge (it runs code in every job), and every additional mirrored internal is another thing that can drift out from under us. A glob in an enablement pattern is not evaluated at all — we carry no matcher — so the extension is copied and the command says it could not honour the pattern. Fail open, and say which. That reach, together with git-sourced packages and project-local installs, isOQ-019, recorded rather than glossed. - What this design buys with an unexported dependency, stated as a cost.
worker/src/host-pi.mjsreimplements two pi internals that have no public equivalent: the user-scope install-path lookup and the enablement grammar. Two pins at different distances bound it — a contract test against the resolved artifact, a canary againstlatest, sharing one needle list — but neither catches a mirror that was reading the wrong lines from the start. The residual isOQ-018, and it is the reason the bullet above resists widening the mirror further.
- Read pi's
- Traces to:
REQ-GLOBAL-PI-OVERLAY,INT-CONTAINER-RUNTIME-CONTRACT,INT-SDK-SESSION-OPTIONS,INT-PI-PACKAGES-FILE-CONTRACT,INT-TRIGGERS-FILE-CONTRACT,INT-CONTAINER-JOB-INPUTS,CONST-ISOLATION-CONTAINER-PER-JOB,CONST-TOKEN-SCOPED-PER-JOB,CONST-PI-VERSION-PINNED,DES-FLOWS-ARE-DATA-PERSONA-IS-CODE
- Decision:
@playwright/cliwith Chromium bundled in the job image, withPLAYWRIGHT_BROWSERS_PATHset at both build and run. - Why:
@playwright/cliis headless by default and built for agents, so it works in a container with no display server. ThePLAYWRIGHT_BROWSERS_PATHdetail is not incidental — it resolves a direct collision between two of our own constraints. Installing Chromium as root at build time puts it in/root/.cache/ms-playwright; the non-root runtime user thatCONST-ISOLATION-CONTAINER-PER-JOBrequires has a different$HOMEand cannot see it. Setting the variable at both stages makes install and lookup agree. Note pi-playwright itself has no browser-resolution logic — it delegates entirely to standard Playwright resolution — so this is ours to get right.@playwright/clidoes not install browsers. Verified from the published tarball: it is a 115-line wrapper withbin: {"playwright-cli": …}and no browser-fetch code; its owninstallsubcommand installs agent skill files, not binaries. Browsers come from the standard installer —npx playwright install --with-deps chromium. Note it depends onplaywright@1.62.0-alpha-1783623505000— an alpha, pinned exactly by the package itself; treat that as another upstream pin to watch. Chromium must run--no-sandbox— seeINT-CONTAINER-RUNTIME-CONTRACT. The alternative is re-grantingCAP_SYS_ADMINor widening seccomp, which trades the container boundary for Chromium's internal one against adversarial input. The container is the sandbox. - Evidence (upstream):
guwidoe/pi-playwright @ 7d3eeeda—PLAYWRIGHT_BROWSERS_PATHappears nowhere in the repo;scripts/pw.jsis a passthrough to@playwright/cli·npm @playwright/cli@0.1.17—bin: {"playwright-cli": "playwright-cli.js"}, deps pinplaywright@1.62.0-alpha-1783623505000; no installer code in the tarball - Reference (no authority): Playwright docs — default Linux browser path
~/.cache/ms-playwright; Chromium sandbox needs custom seccomp (clone/setns/unshare) orSYS_ADMINwhen non-root; Chromium download ~281 MB, with--only-shelldocumented as a smaller headless-only variant (a real trade with feature gaps, not a free win — evaluate againstREQ-FRONTEND-VISUAL-VERIFYbefore taking). - Rejected: pi-chrome-dev-tools — drives the system Chrome with a persistent profile. There is
no system Chrome in the container, and a persistent profile contradicts
CONST-ISOLATION-CONTAINER-PER-JOBdirectly. It is a desktop tool. - Traces to:
REQ-FRONTEND-VISUAL-VERIFY,INT-CONTAINER-RUNTIME-CONTRACT
- Decision: A per-folder/repo scheduled pause (
REQ-SCOPED-PAUSE-WINDOWS) is enforced by deferring the job, not dropping it: in the processor, before any spend,pauseUntilMs(windows, job.data, now)returns the window-end ms for a scope-matching active window, and the worker callsjob.moveToDelayed(end, token)then throwsDelayedError(BullMQ's own recognise-as-delayed signal). Windows live in a validatedpause-windows.json, boot-loaded fail-loud and live-reloaded through a directory watch — the exacttriggers.jsonmachinery (shared validator, atomic write, keep-last-good-on-bad-edit). The predicatepauseUntilMsand its timezone helpers are pure and injected-nowtestable; timezones use the built-inIntl(a one-pass offset correction, DST-correct outside the ~1h transition seam). - Why:
- Defer, not drop. A
{ outcome: "policy" }return (the existing refusal shape) would drop the job — fine for over-budget, wrong for "pause then run after": a github issue job has no re-trigger, so dropping loses it.moveToDelayedkeeps the job's identity/dedup (the delivery-GUID jobId), survives restart (Redis-persisted delayed set), and auto-resumes with no explicit unpause when BullMQ re-picks it. - Not the global pause. BullMQ's
queue.pause()is whole-queue, untimed, and has no per-job worker hook to scope by folder/repo. The scoped gate is a separate check inside the pickup path; the two compose. - Before
reserveBudget. The gate sits first in the processor wrapper — before the kill timer, the settings read, and the budget reservation — so a deferred job arms no timer, reserves no slot, and spends nothing. Same placement discipline as the branch-protection and token-cap gates; consistent withCONST-BUDGET-BEFORE-TOKENS(a deferred job is not a job start). - Library-first + no new dependency. BullMQ owns the delay;
Intlowns the timezone math. Keyed onjob.data.repo(github) /job.data.folder(local) byjob.data.kind,"*"matching all.
- Defer, not drop. A
- Traces to:
REQ-SCOPED-PAUSE-WINDOWS,INT-PAUSE-WINDOWS-FILE-CONTRACT,CONST-BUDGET-BEFORE-TOKENS,DES-CRON-VIA-BULLMQ-SCHEDULER(the live-reload template),DES-ADMIN-VIA-PI-EXTENSION(the confirm-gated CRUD)
- Decision: Which transcript a job resumes is computed from the job, never looked up. A forge job
keys on
(repository, head branch); a cron job on its scheduler id; everything else resolves no key and cold-starts. There is no index, no manifest, and nosessions.json. - Why: The issue that asked for this proposed recording the session id and head branch in the run
record and scanning back for the producing run. That is the obvious design and it is the wrong one here,
for a reason this file has already settled once: an index is a query surface, a query surface is the
database
DES-RUN-HISTORY-FLAT-FILES-NO-DBand theinterfaces.mdpreamble both refuse, and it would arrive as a second retention authority beside the reaper. The derived key also buys the safety property the issue asked for separately. "A session must only ever be resumable by a job for the same repo and PR" becomes unrepresentable rather than merely unlikely: the key is built from the base repository and a ref, so there is no expressible way to name another repository's transcript, and a fork resolves nothing at all. What makes it possible is that the join already exists and nobody had to write it down. An issue-triggered job is told to push topi/issue-<n>, so the pull request's head ref IS the issue's branch, and both sides are host-computable. That single fact is the entire case for a branch-shaped key, and it is whybranch.mjsexists: the prompt and the key must name one string. - Rejected:
- An index or manifest mapping jobs to sessions — the database, refused above. It would also be the first cross-record content query in the project.
- Keying on the job id — a new job has a new id; there is nothing to look up.
- Keying on the PULL REQUEST number — the tempting one, and it fails for the reason that matters. The
number is forge-assigned and not attacker-chosen, which is strictly better than a branch name. But
nothing host-side joins issue
#7to the pull request#8its job opened without recording it, and recording it is the index. The branch is the only host-computable join, and its name-forgeability is the price (OQ-014). SessionManager.continueRecent— it scans a directory and resumes whatever ran last there. One call, looks exactly like what this feature wants, and is the cross-author leak in its purest form.- Mounting the shared store into the container — one job could then read and rewrite every other repository's transcripts. Not a weakening of container-per-job but its inversion.
- Readable per-key directory names — a branch ref is attacker-influenced free text, and using it as a
host path segment moves the whole problem into a validator. Hashing makes traversal unreachable and
keeps the store listing PII-free by construction, the same property
local:<basename>gives the run record. The cost — an operator cannot eyeball which directory is which — is answered bykeyParts.
- Traces to:
REQ-RESUMABLE-SESSION,INT-SESSION-STORE-CONTRACT,DES-RUN-HISTORY-FLAT-FILES-NO-DB; implemented inworker/src/session-key.mjs.
- Decision: To let an operator inspect what a run built, retain the run's inputs and start a new
container, rather than preserving the original one in any form. The job container stays single-use,
--rm, TTY-less and port-less;pi-dispatch sandbox <jobId>launches a second, differently-named container from the same image with the same mounts and no credentials (REQ-RESURRECTABLE-SANDBOX,INT-SANDBOX-CONTRACT). - Why: The thing an operator actually wants is the app running against the files the agent wrote. That needs the image and the workspace — both of which already exist and are already cheap to keep. It does not need the original process tree, and every design that tries to keep one trades away a property the whole security model rests on. Framing it as "make it reproducible" rather than "make it survive" is what keeps the change confined to how long a directory lives.
- Rejected:
- Keep the job container alive and
docker execinto it. A job container with an open operator channel is a different security object from the one every isolation flag was chosen for: it is alive while adversarial code has run in it, it still holds the minted forge token and the provider key in its environment, and--rm— the flag that makes leakage between mutually-untrusting issue authors structurally impossible rather than merely unlikely — has to go. The convenience is real and it is not worth reopeningCONST-ISOLATION-CONTAINER-PER-JOBfor. - A stdin channel to the running agent. Same objection plus a worse one: it makes the operator an input to a session whose prompt already carries untrusted issue text, so the two trust classes meet inside a running agent rather than at a boundary.
docker committhe container at exit. This preserves strictly more — installed packages, process residue — and costs gigabytes per run to do it. It serves a 5% case that image+workspace already serves, and the extra it preserves is mostly the part that should have been in the image. The honest version of the contract ("same image, same workspace, fresh processes") is the one that stays cheap.- Publish a port on job containers, gated by config. An always-available network surface on the
untrusted side, live for every run, to serve the runs an operator is watching. The publish flag
exists only on an operator-started sandbox and only while it is up, bound to
127.0.0.1. - Exempt
pi-sandbox-*from the boot reaper by editing its filter. The reaper'sname=pi-job-filter is a substring match, so a separate namespace already achieves this with no change to the reaper at all. Editing the filter would put the guarantee in the reaper's code; keeping the names disjoint puts it in the names, where a test can pin it. - Widen
makeLogReaperto sweep retained directories too. Its.log/.jsonfilter andlogsDirscope are a documented contract, and these directories have a different retention policy, a different PII class, and one requirement neither sibling has — asking docker what is live before deleting.session-store.mjs'sreapSessionsalready set this precedent for the same reasons.
- Keep the job container alive and
- Traces to:
REQ-RESURRECTABLE-SANDBOX,CONST-ISOLATION-CONTAINER-PER-JOB,CONST-TOKEN-SCOPED-PER-JOB,INT-SANDBOX-CONTRACT,DES-RUN-HISTORY-FLAT-FILES-NO-DB
- Decision: Implement replica runs (
REQ-REPLICA-RUNS) by threading a single host-assigned integer — the 1-basedreplicaindex — through the four layers that would otherwise collapse N attempts into one, and changing nothing else. The index reaches the BullMQ job id, the semantic dedup key, the minted branch, and the prompt. It deliberately does not reach the session key,/job/event.json, the budget, or any container flag. - Why: The layers that prevent this are not obstacles to route around; each is a control someone chose
and each stays exactly as strong for an unflagged run. The cheapest way to keep that true is to make the
discriminator one value with one owner and let everything keyed off a job id inherit it for free —
the container name (
index.mjs),PI_JOB_ID(run-container.mjs), and the.log/.jsonsidecars (run-history.mjs) all become replica-distinct without being told. The work then reduces to four deliberate additions rather than a feature flag threaded through the worker. - The branch is the load-bearing one, and
issueBranchis where it belongs.branch.mjsexists because the prompt and the session key must not each spellpi/issue-${n}— a second copy would not fail, it would key a session on a branch the agent was never told to push to. A replica adds a third fact to that same argument:session-key.mjscallsissueBranchwith one argument and must keep doing so, which is safe only becausetriggers.mjsrefusesreplicasbesideresume. The coupling is written into all three files, because it is invisible from any one of them. - Where the index deliberately stops.
- Not the session key. Adding it would be the wrong fix for a problem the refusal already prevents, and it would create a second, silently-diverging notion of which transcript a job continues.
- Not
/job/event.json. That literal is the webhook's own body plus one decision record (INT-CONTAINER-JOB-INPUTS,INT-WEBHOOK-PAYLOAD-SUBSET); an execution knob is not a fact about the delivery. The agent learns its index from the prompt, andPI_JOB_IDalready ends-r2. - Not the budget. N reservations is the honest count (
CONST-BUDGET-BEFORE-TOKENS).
- Rejected:
- First-finished-wins with sibling cancellation. Half a cancelled run has already spent its tokens, so the saving is illusory — and it destroys the comparison the feature exists to produce. There is also no cancellation machinery to reuse; building one to make the feature worse is a poor trade.
- Auto-judging the two pull requests. A third paid agent, ranking two agents, to save a human one diff read. Two pull requests, one human, done.
- An asymmetric branch scheme where replica 1 keeps
pi/issue-<n>. It reads as an original and a copy, which is precisely the framing that makes an operator stop comparing them. Suffixing every replica costs one string and keeps the pair symmetric; an unflagged run is unaffected either way. - Replicas for
local/cron triggers. A local job's/workspaceIS the operator's folder, bind-mounted read-write. Two replicas would edit one working tree with no gate and no undo — the hazard is the reason, not the scope of v1 effort. - A
PI_REPLICAenvironment variable. The env allowlist is closed by design (INT-CONTAINER-RUNTIME-CONTRACT), and nothing inside the container needs to branch on the index: the prompt names the branch, and the runner treats every job identically. A variable would be a second place for the index to live and a second place for it to disagree with the branch. - A
replicafield inevent.json. See above — recorded here as a rejected alternative rather than left as an omission, because it is the first thing a reader will propose. - Deriving the cap from
PI_CONCURRENCYat load.parseTriggersis pure and fs-free and does not read the deployment's settings; a literal3beside the reason (the default concurrency) is honest and reviewable, and the operator who raises concurrency can raise it in the same commit.
- Traces to:
REQ-REPLICA-RUNS,REQ-DEDUP-BY-DELIVERY-GUID,REQ-RESUMABLE-SESSION,CONST-BUDGET-BEFORE-TOKENS,DES-SESSION-KEY-IS-DERIVED-NOT-INDEXED,INT-TRIGGERS-FILE-CONTRACT,INT-CONTAINER-JOB-INPUTS,INT-CONTAINER-RUNTIME-CONTRACT,OQ-017
Considered and declined. Recorded so they are not re-proposed.
- Claude Code GitHub Action — MIT, ~8.4k stars, GA. Already does this trigger spec:
issues: [opened, assigned, labeled]with a dedicated label trigger,issue_comment, cron, and skill invocation from a prompt. It remains the honest 90%-for-10%-effort fallback and the README says so. Declined because it ties execution to GitHub-hosted runners and their minutes, gives less control over the browser environment and model choice, and — the actual point — this project is about running pi. Note it validates every pattern here, including having no queue of its own. - OpenHands resolver — a second proof of the label-trigger pattern (
fix-melabel → agent attempts the issue). Different agent; documented reliability issues. - GitHub Actions + a self-hosted runner invoking pi headlessly — genuinely attractive: GitHub absorbs the burst, our hardware runs the work, zero queue infrastructure. A legitimate v2 direction. Declined for v1 because webhook→BullMQ is easier to debug than runner plumbing, and because the queue semantics we want (priorities, budget cap, dashboard, dedup) are exactly what Actions does not give.
pi-harness(zosmaai/openzosma) — the closest prior art: "the top-level harness for the Pi ecosystem… run pi-coding-agent headlessly as a background HTTP/SSE server." It solves the run-pi-headlessly half and nothing else — no triggers, no queue, no container-per-job. It is a server; this is a job system. Single publish at 0.1.1 (2026-04-26), no releases since.pi-sentry— an in-process permission/impact gate extension for pi, classifying tool calls low/medium/high. Does not changeCONST-ISOLATION-CONTAINER-PER-JOB: it runs inside the agent process, custom extension tools execute on the host regardless, and it documents a "YOLO" level that bypasses classification. It is a useful interactive UX guard, not a boundary against adversarial input.- Gondolin micro-VM — see
CONST-ISOLATION-CONTAINER-PER-JOB. Routes only built-in tools.
pi-dispatch/
specs/ ← this directory: the source of truth
receiver/ # Express webhook ingress. Public edge. No dashboard.
admin/ # pi-extension admin surface (slash commands + TUI). See DES-ADMIN-VIA-PI-EXTENSION
worker/ # BullMQ worker, docker orchestration, GitHub token minting
image/ # Dockerfile + entrypoint + /runner (SDK job runner)
flows/ # frontend-fix.md, bug-fix.md, triage.md — DEFAULTS, seeded into the data volume
persona/ # hard rules; baked into the image. Not runtime-editable
deploy/ # docker-compose runs Valkey by default; `--profile receiver` adds the containerised
# receiver (issue #82 — it has zero docker dependency and is the internet-facing
# piece). The WORKER is always a host Node process (DES-WORKER-ON-HOST); no service
# mounts docker.sock. Unit templates: systemd verified-structure; launchd/nssm
# worked examples — all render-installable via `pi-dispatch service` (issue #80).
.env.example # provider key, spend/concurrency knobs, VALKEY_URL, PI_JOB_IMAGE
docs/
flows/, not skills/. "Skill" already means three different things in this ecosystem: pi's
installable packages (pi install npm:…), a package's registered skill (pi-playwright's
playwright-browser), and our per-label job definitions. Renaming the one we control costs nothing and
removes the ambiguity at its root — the alternative is a glossary that explains a collision we could
simply not have.
Build order: image + runner + persona (headless pi proven in isolation) → worker → receiver → flows
→ admin extension → deploy + hardening. The first step is deliberately the one that needs no queue and no GitHub:
it is where the SDK traps in INT-SDK-SESSION-OPTIONS live, and they are cheapest to find with nothing
else in the frame.
Platform: Windows, macOS and Linux, wherever Docker runs. docker-compose is the supported
deployment; the systemd unit is a verified-structure per-host template — its structure statically checked
by systemd-analyze, its placeholders unresolved, so it is neither turnkey nor end-to-end tested — and
the launchd (.plist) and Windows (nssm) units are added as untested examples. Two consequences are not
incidental and are tracked where they bite: a containerised worker talking to the Docker socket resolves
bind-mount paths in the
daemon's namespace, not its own; and a home machine behind NAT cannot receive GitHub webhooks without
a tunnel.
| Date | Change |
|---|---|
| 2026-08-13 | Issue #189 (Gap 1, doctor half). DES-FLOW-RESOLUTION-TWO-ADVISORY-LAYERS AMENDED: the doctor layer is now specified in full — per-tuple lines in loader precedence order; repo tier read with the gate's ls-tree mechanics but HEAD-resolved by doctor and degrading to unknown on git failure (readFlowGate was considered and REJECTED for this read: its fail-closed catch turns a broken folder into deny, and "deny implies the file exists" would print a confident wrong ✓; the gate's no-ref rule guards against an agent self-authorizing, which a host preflight does not face); staged tier via the new readStagedSkills whose pattern-manifest packages read as not-enumerable because manifest patterns can DISABLE files and a wrong ✓ is the one inadmissible error direction. DES-AI-TRIGGER-FLOW-GATE UNCHANGED, checked — the gate itself is untouched; doctor copies its read mechanics rather than calling it, precisely so gate semantics stay pure WHO-may-fire. DES-CLI-SURFACE UNCHANGED, checked — doctor stays read-only/always-safe; the new checks carry no fixAction. |
| 2026-08-13 | Issue #189 (Gap 1, runner half). NEW DES-FLOW-RESOLUTION-TWO-ADVISORY-LAYERS: flow resolution is verified at two advisory layers and refused at neither — the runner compares PI_FLOW against the LOADED skill names post-load, pre-session, pre-spend (isFlowLoaded, exact equality, disableModelInvocation counts) and reports a miss as one flow_not_loaded line; doctor is the approximate host-side layer, landing with the companion change. Records why report-not-refuse (flow is by doctrine a prompt hint; a refusal shipped in an image upgrade fails yesterday's jobs and burns a budget slot per delivery), why the runner layer is the exact one (pi names a skill frontmatter.name || parentDirName at the pin, so only the loaded set is authoritative), and the rejected alternatives (boot-time failure — parseTriggers stays pure and absent-at-HEAD is a legal steady state; event.json carriage — execution knob, not a delivery fact; a new top-level outcome — admin surfaces drop unknown outcomes, new vocabulary rides a reason). DES-AI-TRIGGER-FLOW-GATE UNCHANGED, checked — the gate answers WHO may fire a flow and keeps its pinned-sha object-store read; the new entry answers whether the flow EXISTS in the box, and neither consults the other. DES-TRIGGERS-UNIFIED-FILE UNCHANGED, checked — the shared validator gains nothing; the flow travels as job data the queue already carried. |
| 2026-08-12 | Issue #181 (the budget lever and the trend lines). DES-COST-FOLD-BY-SCAN AMENDED: the fold gains dailyByFlow — the composite (day, flow) fold at the same loop buildDaily and buildByFlow already walk separately, gap-padded per flow over the SHARED span (small multiples are only comparable on one x-domain) with the machine-key/display-label split held (flowLabelOf extracted so the two flow folds cannot drift on what a flow is called). The series shares daily's first-run origin for the same sparkline-density reason recorded on the sinceMs row. CONST-BUDGET-BEFORE-TOKENS UNCHANGED, checked — readBudget's GET-only posture now covers the token counter too, and the new junk-URL parse guard degrades synchronously (the readSchedulers failFast posture; without it a canned "not-a-url" fixture burns the full timeout per test). DES-ADMIN-VIA-PI-EXTENSION UNCHANGED, checked (the dashboard's budget meters and /dispatch budget are untouched; the page ADDS a display, replaces nothing — the "no replacement on that" ruling). |
| 2026-08-12 | Issue #181 (insights becomes the ONE analytics surface). DES-ADMIN-VIA-PI-EXTENSION AMENDED: six in-component views become FOUR — the COSTS and GRAPH views leave the overlay for the insights artifact, taking their two per-view refresh policies with them (the stale-gated tick piggyback and the entry-plus-r-only posture existed for those fetch paths; the overlay is back to one snapshot poll plus the tail read); the slash-command list drops costs and graph for insights; the graph-html paragraph re-homes onto the bare insights command; the LIST footer's c costs/g graph pair becomes i insights (51+18=69 of 76 columns, the arithmetic comment re-run), and the i key resolves the overlay with a done-action so index.ts writes and opens the page between overlays — the addTrigger route, deliberately not a dep seam and not a TUI suspend bracket, and deliberately BEFORE the dialog guard (the action needs no dialogs, and an older pi without them must still reach the one analytics surface). DES-QUEUE-BULLMQ-OVER-CUSTOM's parenthetical names the insights artifact now. The dashboard's fs ban is UNCHANGED, checked, and dashboard.ts drops its pricing/costs/graph-model imports entirely. DES-COST-FOLD-BY-SCAN UNCHANGED, checked (the fold and its joins are what the page is made of; nothing about them moved). DES-GRAPH-EDGE-DERIVATION UNCHANGED, checked (the edge rules' one home; the model gained no vocabulary). |
| 2026-08-12 | Issue #175 (the insights artifact, the fourth slice). DES-ADMIN-VIA-PI-EXTENSION AMENDED: insights html joins the slash-command list (bare insights answers usage — the artifact IS the feature), completion covers insights html <window>, and the artifact lands beside graph.html under the one graphDir (a second directory would churn resolvePaths and the wizard for zero capability). The no-port property (:913) and the served-page rejection (the #54 row) are not reopened: this is a second file:// artifact, same socket-to-file substitution. The factoring facts are load-bearing and recorded here: graph-html.mjs may be a source of exports for a sibling pure emitter, it may never load one itself — its purity pin is substring-level and directional, which is exactly what makes the reuse safe — so buildGraphScene (the normalize+layout+SVG-emission half of buildGraphHtml, a behavior-preserving extraction) is what insights-html.mjs composes, and the money strings come from the REAL panel.mjs formatter (zero own module loads there), not a hand-copied twin. Rejected: a shared third emitter module (impossible under the substring ban without weakening it); duplicating the layout/escaping (an escapeHtml drift between two artifacts is an XSS waiting); folding costs into graph.html in place (an operator sharing topology should not be forced to share spend); a charting library (the file:// posture forbids external requests, and hand-rolled rectangles need no supply chain). DES-GRAPH-EDGE-DERIVATION UNCHANGED, checked. DES-COST-FOLD-BY-SCAN UNCHANGED, checked (the artifact consumes the fold; the fold learned nothing new). |
| 2026-08-12 | Issue #175 (spend and schedule on the graph, the third insights slice). DES-GRAPH-EDGE-DERIVATION AMENDED, one clause: a trigger node may carry cost (the typed spend foldTriggerCosts mapped onto its node id) — spend is a node fact beside runs/lastOutcome, NOT a new edge kind and NOT a flag, so the closed vocabularies and their pins stand byte-identical; the assemblers wire it with one extra file read (subscriptions) and two pure folds over the scan they already paid for, which is why the GRAPH view's entry-plus-r-only refresh policy is untouched (the real-poll pin proves it). DES-ADMIN-VIA-PI-EXTENSION AMENDED: gtrigger rows phrase next as a countdown against the model's own generatedAt, never a live clock — a stale model shows its stale countdown honestly, and render() stays a pure read of state. graph.html's normalizeModel allowlist gains meta.chainRefusals, meta.injectedUnreachable and observed-edge lastEndedAt; node cost is deliberately NOT allowlisted there (the page cannot use the from clause, and a hand-copied money formatter pinned by parity test is a cost the insights artifact avoids by taking the real formatter). DES-COST-FOLD-BY-SCAN UNCHANGED, checked. |
| 2026-08-12 | Issue #175 (per-trigger and per-repo spend, the second insights slice). DES-COST-FOLD-BY-SCAN AMENDED: the fold gains byTrigger/byRepo rollups and the foldTriggerCosts node-id-keyed spend map, with the join passed IN (attributeRunsToTriggers, new in the read-model beside joinRunsToTriggers, whose index+type doctrine and cron jobId grammar it reuses verbatim — triggerMatchLabel is now exported from graph-model so the label vocabulary has one home); "the fold re-deriving the join" joins the Rejected list. repoOfTarget moves the target-stripping grammar into costs.mjs and forgeRepoTargets now calls it, so the graph's repo list and the cost fold's repo table can never disagree on what a repo is. DES-ADMIN-VIA-PI-EXTENSION AMENDED: the COSTS view's f cycles four tables; the trigger join adds one FILE read (readTriggers) to fetchCosts — no spawn, so the 10s stale-gated poll piggyback policy stands and the GRAPH view's entry-plus-r posture is untouched. DES-GRAPH-EDGE-DERIVATION UNCHANGED, checked. |
| 2026-08-12 | Issue #175 (cost-fold correctness, the first insights slice). DES-COST-FOLD-BY-SCAN AMENDED: window.days — the proration denominator — now comes from the requested window (sinceMs, the same instant the caller cut the scan at), with firstRunMs riding beside it for renderers that want the observed left edge and the observed-span derivation kept for window-less callers; the first-observed-run derivation moves to Rejected as a refuted correction (a sparse window shrank the denominator and flipped verdicts to SAVING). COSTS_WINDOWS/costsSinceMs move INTO costs.mjs beside the fold so the scan cutoff and the denominator cannot drift — the dayKey-import reasoning at day grain, applied at window grain. Daily buckets still start at the first observed run, deliberately: a month of leading zero cells on a young deployment would compress the sparkline's visible history to nothing. DES-SUBSCRIPTIONS-ARE-COUNTERFACTUAL-ONLY UNCHANGED, checked. DES-ADMIN-VIA-PI-EXTENSION UNCHANGED, checked (no new view, no key change — the COSTS view's refresh policy and layers are untouched). |
| 2026-08-11 | Issue #54 (the HTML export — the slice #54's own text ruled out, landed by narrowing what was actually ruled out). DES-ADMIN-VIA-PI-EXTENSION AMENDED: graph html writes a self-contained HTML artifact (inline SVG/CSS/JS, file://, zero external requests) atomically to the stable <graphDir>/graph.html and best-effort opens the browser via the worker's shared opener, printed-URL-first, skip-and-say over SSH/headless. The Why this row exists: issue #54 said "no web/HTML surface — the admin binds no port; that property is load-bearing", and the property survives INTACT, because the property was always the port. A file with no server is not a surface: nothing listens, nothing off-machine gained reachability, and the socket→file substitution is the same one DES-JOB-OUTBOX-CHAINING canonised for the outbox. DES-QUEUE-BULLMQ-OVER-CUSTOM's "drops the web surface entirely" line — the one a reviewer would quote against this — is REWORDED to "drops the SERVED web surface" rather than argued around. Two new Rejected entries record the real lines: a served graph page (the removed surface re-proposed with a prettier face) and raw .log bytes in the artifact (the placement boundary does not become an escaping promise in a durable file). Content rule: run-record fields and operator-authored strings only; folder basenames, never host paths. The write is the writeTriggers idiom (atomic, named path, fail-loud); the spawn seam is index.ts's, the dashboard stays I/O-free, USED_API stays four members. New OQ-024 records the opener-spawn WATCH residual. |
| 2026-08-11 | Issue #54 (the GRAPH view). DES-ADMIN-VIA-PI-EXTENSION AMENDED: the overlay gains its sixth view, GRAPH (g) — the topology from the same assembled model as /dispatch graph, so the two surfaces cannot disagree (DES-GRAPH-EDGE-DERIVATION stays the one home of the edge rules). The data path is stricter than COSTS on purpose: fetch on entry and on r only, never on the poll tick, because fetchGraph spawns git per enumerated folder — pinned by a test that runs a real 10ms poll and counts fetches. The LIST footer absorbed g graph by merging the pause/resume pair into one p/r pause hint and dropping ↵ from the nav hint — still exactly 76 visible columns at width 80, ellipsis-free, pinned. Two overdue postures landed with the view: PI_DISPATCH_ASCII=1 now flips the OVERLAY styler too (makeStyler's per-instance ascii, threaded from the same resolved paths as the setGlyphs funnel — the half-ASCII gap the 2026-08-01 row's "at extension load" phrasing papered over), and the graph rows' glyphs (arrowRight/foldOpen/foldClosed/rearm) join the styler twin tables width-identical, so the 80-col invariant holds on ASCII terminals. The unframed degrade reuses renderGraph whole (uncollapsed, the everything-else-failed rendering). The fs ban UNCHANGED, checked — fetchGraph is a createDashboardDeps seam over read-model functions like every other byte the overlay renders. The tool surface UNCHANGED, checked (no dispatch_graph; the count pin stands). |
| 2026-08-11 | Issue #54 (/dispatch graph). DES-ADMIN-VIA-PI-EXTENSION AMENDED: graph joins the slash-command list — an operator-typed, ungated read on the runs/costs tier, rendering renderGraph(assembleGraph(...)) into the admin channel with triggerTurn never set. The subcommand is deliberately NOT an LLM-callable tool (the enumeration spawns git per folder; the tool-count pin stands). The USAGE string and KNOWN_SUBCOMMANDS array are now pinned to agree member-for-member by a wiring test, closing a drift class this amendment would otherwise have widened. The dashboard's source-regex fs ban UNCHANGED, checked; the five-views count UNCHANGED for now (the GRAPH view is the next slice and will amend the Decision when it lands). |
| 2026-08-11 | Issue #54 (the model assembler). NEW DES-GRAPH-EDGE-DERIVATION: the graph's edge honesty rules, in one pure fold (buildGraphModel). Four evidence classes (config/observed/potential/cron-rearm, a closed test-pinned vocabulary), the two OQ-009 structural prohibitions (no forge-parent chain edges, no cross-folder chain edges — the harness makes both unrepresentable, so drawing either would draw a lie), precise dangling (no-skill only where enumeration succeeded; unverified is not dangling; charset-invalid is its own flag because the gate's deny proves nothing about existence), three-way orphanhood, caps and honesty counters on every model. The interesting rejections are recorded: an all-pairs gate-eligibility fabric (eligibility is a node badge, a mention is the edge, or the graph is noise) and first-match resolution of ambiguous observed-edge targets (dropped-and-counted beats pinning real history onto the wrong folder). DES-JOB-OUTBOX-CHAINING UNCHANGED, checked (the graph consumes its record fields and caps; nothing about collection moves). DES-COST-FOLD-BY-SCAN UNCHANGED, checked (same scan, second consumer, still fold-time-derived and never stored). |
| 2026-08-11 | Issue #54 (the data layer under the trigger/flow graph). DES-ADMIN-VIA-PI-EXTENSION AMENDED, and this row says out loud what the last three dashboard rows certified as unchanged, because this time it DID change: a new read-model surface and new fs/git access. readFolderSkills enumerates a cron folder's committed skills from the git OBJECT STORE at HEAD via the worker's own selectEntries/keepOnlyDeclaredSkills (a ./materialize exports-map subpath added for exactly this — re-deriving the listing parse admin-side is how the graph would show a skill the job path never materialises), one hardened ls-tree plus one bounded cat-file per top-level SKILL.md, with the frontmatter read through the gate's own newly exported aiTriggerAllows. readInjectedSkills lists a run.skillsDir from the working tree and is labelled advisory (the doctor precedent; host files have no object store to prefer). cronRunStats/joinRunsToTriggers/observedChainEdges fold already-scanned records into the joins the graph will draw; collectGraphInputs is the one dedupe/caps funnel over the folder spawns. Everything is never-throw, degrades per folder to a discriminated unreachable, and is bounded by the frozen, literal-pinned GRAPH_LIMITS. Three properties re-affirmed rather than assumed: the enumeration is DISPLAY-ADVISORY and never a gate decision (DES-AI-TRIGGER-FLOW-GATE's pre-agent-sha truth untouched; HEAD-at-display-time answers what the NEXT run will see, a different question, and an unreadable SKILL.md reads as NOT chainable); the dashboard's source-regex fs ban UNCHANGED, checked (every new spawn and read lives in read-model.mjs); the .log placement boundary UNCHANGED, checked (the folds read .json records only). resolvePaths mirrors the chain caps with defaults IMPORTED from the worker (new CHAIN_DEPTH_MAX_DEFAULT/CHAIN_MAX_PER_JOB_DEFAULT exports) so the graph can never state a cap the worker does not enforce, and readTriggers display records now carry the RAW triggers-array index — the identity matched.index counts, cron entries and unusable rows included, so a dropped row leaves a hole rather than renumbering every attribution below it. The graph VIEW itself is a later slice; this row is only its data. |
| 2026-08-09 | Issue #60 (Gap 3). NEW DES-TRIGGER-INSTRUCTION-IN-THE-ENVELOPE: the operator's text goes in the user prompt's envelope, above the fenced data region and BEFORE the never-merge paragraph, because later text reads as more specific and the harness's non-negotiables must not look like something an operator instruction is qualifying. Five rejected alternatives recorded, and two of them are the interesting ones. Inside the fenced data region it would be DOCUMENTED TO BE IGNORED, since dataRegion tells the model everything below its heading must be reported rather than obeyed -- the accepted-where-it-does-nothing hazard. In the system prompt it would work and be marginally cheaper per turn, and is still refused: every other member of that layer is read from a fixed file path once at loader build, which is the shape CONST-PERSONA-IN-CACHED-PREFIX's acceptance leans on, and run.task is already contracted user-prompt-only, so two operator text fields with two placements would be an incoherence. Also rejected: reusing run.task on webhook triggers, giving local jobs an envelope so cron could take the field, and content-filtering the operator's text (placement is the boundary; the delimiter is defence in depth, and this module's docstring already refuses that reasoning for the payload). Putting it in the envelope is what leaves dataRegion UNCHANGED, so the shared export keeps its signature and the new-parameters-go-last rule is honoured without threading a hole through three sibling forges. DES-FLOWS-ARE-DATA-PERSONA-IS-CODE UNCHANGED, checked: its clarification already admits operator-authored deploy-time config, and the reviewed triggers.json is that; the admin-editable runtime channel it actually bars is untouched, since no dispatch_trigger_* parameter was added. |
| 2026-08-09 | Issue #60 (Gap 2). NEW DES-TRIGGER-SKILLS-COPIED-NOT-MOUNTED: the injected skills are COPIED into the per-job dir rather than bind-mounted, and the mount-count argument is deliberately recorded as the WEAKEST of the three reasons. The copy is the PIN: :ro bounds the container and not the host, and pi reads a skill's body on demand, so under a live bind an operator editing their directory would change the instructions of a job already running. It also answers symlinks once on the host side, where loadSkillsFromDirInternal would otherwise follow both file and directory links -- a directory symlink at / would have turned skill discovery into a walk of the container filesystem. And it adds no mount, so this entry CAN borrow the argument the 2026-07-31 /session row explicitly could not. Four rejected alternatives recorded, including the per-trigger :ro bind the issue originally sketched (with the honest qualification that a bind's source path is legible via /proc/self/mountinfo) and an env var naming the injected root (a second source of truth that can disagree with the filesystem, silently and in the expensive direction). DES-AI-TRIGGER-FLOW-GATE AMENDED: injected skills are trigger-reachable and never AI-reachable, and it FALLS OUT rather than being built -- the gate reads the object store at a pre-agent sha and an injected skill has no object-store presence, so no-skill and both callers refuse. The corollary is stated because an operator cannot discover it: an injected ai-trigger: allow is never read, doctor warns, and the residual is OQ-022. DES-OPERATOR-GLOBAL-OVERLAY UNCHANGED, checked -- its own rejected /opt/pi-packages:ro mount is the precedent the new entry cites, and the overlay's tier is unmoved. |
| 2026-08-08 | Issue #66 (ingest pull_request_review). DES-PR-TRIGGER-ROUTES-TO-FLOW AMENDED: a submitted review routes through this same decision rather than getting one of its own — the harness still implements no review behaviour, does not change the clone ref, and hands the flow the PR context plus the review's four fields. Two consequences recorded because they are the shape of the decision rather than details of it: review.state reaches the flow as DATA, so "only act on changes_requested" is a flow decision, while on.reviewState is the operator's separate and cheaper control over what is worth paying for at all (the same split as a label predicate versus what the skill does once it runs); and review.id is carried because the review's inline comments ride an event this project does not ingest, so fetching them is the flow's job. Rejected gains two entries: a fifth on.type for reviews (GitLab's approved already rides pull_request), and ingesting pull_request_review_comment (one delivery per line comment, a volume characteristic nothing else here has). DES-GH-POLLING-TRANSPORT AMENDED: a fourth source, GET /pulls/{n}/reviews over the OPEN pull requests the PR feed already fetched, so a polled deployment can arm a review trigger at all — without it the trigger loads clean and can never fire, which is the silently dead config this project refuses everywhere else. Its cost model is written down because it is the first per-entity source: validators live in ONE hash keyed by PR number rather than a key per PR, since an unbounded key family would break the "refresh the cursor family as a unit" TTL argument this entry rests on; the idle steady state is one quota-free 304 per open PR; the sweep is bounded and LOGS when it truncates. Two correctness calls stated rather than assumed: the sweep runs even when the open-PR list itself answers 304, because whether a review perturbs that list is GitHub's business and betting on it would mean review triggers that fire only when something else touches the PR; and the cursor is persisted ONCE per sweep rather than per review, because many endpoints' ids interleave, so per-item advance would either re-enqueue or strand — a mid-sweep failure retries the whole sweep and dedups on poll-rv<id>. DES-TRIGGERS-UNIFIED-FILE UNCHANGED, checked — review_submitted and on.reviewState are an action word and a narrowing inside the existing pull_request type, so the file's on × run matrix is untouched. DES-GH-APP-MANIFEST-SETUP UNCHANGED in shape, checked — default_events gains pull_request_review for every new App, armed or not, the same posture pull_request already has for a label-only deployment; existing Apps must add the subscription by hand. |
| 2026-08-07 | Issue #102: DES-OPERATOR-GLOBAL-OVERLAY gains the discovery design and the four calls behind it — read pi's settings.json rather than walking its hoisted node_modules (where an installed package and a transitive dependency are indistinguishable, so a walk would stage code nobody asked for) while capturing the version off disk; treat a convention dir as sufficient, correcting the issue's pi-key predicate against the pinned source; default ON inside --with-packages with the opt-in-for-one-release alternative rejected and recorded; and scope all-or-nothing to the DECLARED set so one bad host package cannot zero a working overlay. Also records the boot-read to per-job-read change and why the original was right at the time, and that skills/prompts/themes enablement plus glob evaluation were deliberately left out. DES-CLI-SURFACE UNCHANGED, checked — --no-host-packages is a flag on an existing command, and the never-tier it defines is what kept the new doctor checks free of a fixAction. |
| 2026-08-04 | A docs audit found six code defects; these are the two that changed a recorded decision (issue #99). DES-TRIGGER-OUTSIDE-PI amended: every forge arm is conditional now, GitHub included. Its identity resolution and WEBHOOK_SECRET requirement were unconditional while the other three arms were gated, so a forge-only deployment could not boot the receiver without gh logged in and a webhook secret it would never use; all three forge docs described a setup that stops at that wall. The uniform gate keeps the property that mattered: skipping identity resolution is sound only because the route is absent too, an unconfigured forge answers 404 rather than 401, and the guard must return if / is ever mounted unconditionally again. REQ-RESUMABLE-SESSION amended (requirements.md): its "one case fails CLOSED" clause was specified and never implemented, so an armed run.resume with no PI_SESSIONS_DIR ran cold and exited green, which is the exact failure the clause exists to prevent; the pre-spend refusal now exists, which also makes two session-store.mjs comments and doctor's fix text true. Cron run.resume moves from accepted-and-silently-ignored to refused at load, on run.replicas' precedent and for its reason. CONST-HMAC-OVER-RAW-BODY UNCHANGED, checked: the secret is still required wherever a GitHub endpoint exists to verify. CONST-BUDGET-BEFORE-TOKENS UNCHANGED, checked: the new session gate is a free pre-spend refusal that reserves no slot. |
| 2026-08-04 | The front door becomes the default route (issue #96). DES-FIRST-RUN-SETUP-WIZARD amended: bare /dispatch with nothing configured lands directly in the wizard's opening select — the select is the consent, replacing the yes/no offer; the outage rule is restated load-bearing (a configured deployment with a down queue keeps the banner, never the wizard). Two steps join the flow: a Docker pre-check (capture probe, Re-check/Continue/Stop loop, per-OS pointers, never a piped installer) and a trigger-edge choice (receiver as a service via a consented pinned @edgehero/pi-dispatch-receiver install + service install --receiver; compose profile with the compose file now shipped in the runtime package and copied create-only; or the polling command printed). A once-per-process skew notice makes re-running setup the visible upgrade path. Fixed in the same change, recorded plainly: service units were broken for every npm deployment — the renderer derived a "repo root" two directories above its module, which in an npm install is the scope directory, so ExecStart/EnvironmentFile/wrapper paths all pointed at nothing; units now anchor on the deployment dir (WorkingDirectory, .env, logs) and resolved script paths (the CLI beside the service module; the receiver via import.meta.resolve), the wrapper execs the argv the render substituted instead of guessing, and npm-layout fixtures now exist so the seam that masked this cannot mask it again. CONST-RETRY-INFRA-ONLY UNCHANGED, checked: the exit-2 conversion survives the wrapper contract change, asserted against the real shipped wrapper. pi compatibility becomes strategy instead of luck: the peer widens to the "*" range pi's own packages doc prescribes for host-provided packages (the exact devDep pin stays the tested marker), a runtime advisory names an untested pi version on first /dispatch (never a refusal — the capability probe stays the only hard gate), and a weekly canary installs latest pi into a scratch dir (never the repo root — the pinned assertions must keep asserting the pin) and fails CI when any used API member or type needle disappears. |
| 2026-08-04 | The console becomes the front door (issue #92). Added DES-FIRST-RUN-SETUP-WIZARD: /dispatch setup + the bare-/dispatch no-deployment offer + a once-ever startup nudge, built as dialogs-first with overlay-per-handoff (the tui suspend handle exists only inside a ctx.ui.custom factory; dialogs cannot run under a capturing overlay; stdin is unreadable while suspended — so each attached child gets its own short-lived overlay, and the child's OWN consent gates are the host-mutation consents: the wizard forwards --yes to nothing). npm step under import-pi's spawn doctrine, with the recorded reasoning for --ignore-scripts on our own runtime (pure-JS deps; msgpackr's native accel is optional with a JS fallback). No new tier, no new powers, no model-callable tool; the only novel artifact is the pointer file (INT-DEPLOYMENT-POINTER-CONTRACT). Rejected on the record: long-lived wizard overlay, clone reuse, detached worker, credential dialogs, auto-writing ai-trigger: allow (two keys stay two keys). DES-TRIGGER-OUTSIDE-PI UNCHANGED, checked: the wizard bootstraps host processes, never hosts them. DES-CLI-SURFACE UNCHANGED, checked: every tier the wizard drives is entered through that entry's own gates. |
| 2026-08-02 | The public URL becomes optional (issue #81, second half). Added DES-GH-POLLING-TRANSPORT: pi-dispatch-receiver poll synthesizes INT-WEBHOOK-PAYLOAD-SUBSET shapes from REST responses (issue events / comments / open PRs; ETag 304s are rate-limit-free; first boot never replays history; per-repo failures never kill the loop) and feeds the unchanged pure filter() + shared enqueue with poll-* delivery ids — the receiver stays the default and the low-latency path. Trust framing recorded: TLS with the operator's own credential replaces HMAC because authentication points the other way; WEBHOOK_SECRET is not required in poll mode, still hard-required for serve. The Actions-runner transport is rejected on the record (merge-gated workflow code executing on the worker host = merge-to-default becomes host code execution outside the container boundary). DES-TRIGGER-OUTSIDE-PI UNCHANGED, checked: the poller is the same always-on process class, just a different transport. CONST-ISSUE-TEXT-IS-DATA UNCHANGED, checked: the poller never interprets bodies. |
| 2026-08-02 | The App path becomes the easy path (issue #81). Added DES-GH-APP-MANIFEST-SETUP: pi-dispatch setup github runs GitHub's App Manifest flow against a throwaway loopback listener — one browser click returns app id + PEM + webhook secret via the unauthenticated single-use conversion endpoint; every .env line is shown before one explicit consent, the PEM lands 0600 and never clobbers, an existing WEBHOOK_SECRET is kept (replacing it would invalidate working deliveries), installation-id discovery uses a deliberately hand-rolled ~15-line node:crypto RS256 JWT (auditable, once-at-setup; job-time minting stays @octokit/auth-app, unchanged), and --no-webhook creates the hook-inactive shape the polling transport will consume. No --yes on this wizard — these writes carry credentials. Rejected on the record: a maintainer-registered device-flow client (maintainer dependency in a self-hosted trust chain) and auto-installing the App (automating a consent screen defeats it). CONST-TOKEN-SCOPED-PER-JOB UNCHANGED, checked: the wizard changes how credentials are acquired, not how job tokens are minted or scoped. CONST-HMAC-OVER-RAW-BODY UNCHANGED, checked: the webhook secret the flow mints feeds the same verify path. |
| 2026-08-02 | The receiver gets a container story (issue #82). Repo-layout deploy/ line updated: docker compose --profile receiver up runs the receiver beside Valkey from a prebuilt ghcr.io/edgehero/pi-dispatch-receiver image (multi-arch, GITHUB_TOKEN-published like pi-job); the default docker compose up stays Valkey-only. The receiver was the natural candidate — grep docker receiver/src is empty, it is the only internet-facing process, and containerising it costs nothing the trust model cares about. DES-WORKER-ON-HOST UNCHANGED, checked: the worker remains a host process — no service in the compose file mounts docker.sock, and the profile's existence changes nothing about why the worker cannot be containerised (client-side path translation, local-folder bind mounts). SECURITY.md's trusted-components row holds verbatim: a containerised receiver still never executes agent-authored content, and HMAC-before-parse is unchanged. |
| 2026-08-02 | The clone stops being the only distribution (issue #80). DES-NAME-KEEP-PI-DISPATCH amended, on its own terms: its change trigger ("wanting to publish any npm artifact under this name — a management CLI") fired, and the resolution is scoped publishing (@edgehero/pi-dispatch = worker + CLI, @edgehero/pi-dispatch-receiver), not the rename — the collision only ever bound the bare name. The amendment also retro-records @edgehero/pi-dispatch-admin, which shipped 2026-07 without a row here: practice had diverged from the entry's unqualified "Do not publish to npm" line, and a constitution that quietly diverges from what ships is worse than none. The two checkout-relative runtime escapes are closed package-relative (worker/.env.example, worker/deploy/ mirrors with byte-equality sync tests against the root copies — the root files stay the documented, edited source). Bare npx pi-dispatch outside a checkout resolves to the squatter's package; docs use scoped forms everywhere. DES-WORKER-ON-HOST UNCHANGED, checked: npm-on-host is the architecturally correct distribution for a worker that must drive the host docker CLI. CONST-PI-VERSION-PINNED UNCHANGED, checked: the pins travel into the published packages byte-identical. |
| 2026-08-02 | Durable running becomes a subcommand (issue #80). DES-CONCURRENCY-3 amended: the one-worker-per-docker-daemon boot-reaper invariant is now enforced at unit-mint time — pi-dispatch service install refuses a worker unit when one exists in the other scope; previously the invariant was one unenforced paragraph. service renders the shipped deploy/ templates by substituting their documented literals (/usr/bin/node → process.execPath, /opt/pi-dispatch → the real repo root) rather than introducing marker syntax, so the templates stay byte-usable examples and deploy-lint keeps checking exactly what ships; a pin test asserts every substitution literal is still present, making template drift a build failure instead of a broken render. The launchd gap is closed in the wrapper, not the plist: KeepAlive/SuccessfulExit=false cannot express exit-code-conditional restart, so worker-env-wrapper.sh/.cmd convert EXIT_POLICY (2) to a clean exit with a loud refusal note — launchd never relaunches a determinate policy refusal, mirroring systemd's RestartPreventExitStatus=2 and nssm's AppExit 2 Exit (CONST-RETRY-INFRA-ONLY UNCHANGED, checked: the conversion is where the supervisor learns what the exit space already meant; the exit protocol itself is untouched). The wrapper's exec gave way to a trap/double-wait form because exit-2 interception needs a live parent — SIGTERM still reaches node via the trap. DES-WORKER-ON-HOST UNCHANGED, checked: service supervises the host process the entry mandates; nothing moves into a container. |
| 2026-08-02 | The CLI surface gets a recorded gate ladder (issue #80). Added DES-CLI-SURFACE: read-only (doctor, status) / operator-typed-ungated (run, pause, resume, sandbox, import-pi) / create-only (init) / consented host mutations (up, doctor --fix — each action shown verbatim, y/N default No incl. non-TTY), plus the load-bearing never-tier (no malformed-config rewrites, no triggers/pause-windows content, no trigger-named run.image pulls — only the deployment default, where the consent keypress is SECURITY.md's "pulled it yourself" act). init/doctor had no recorded surface at all, and the ladder makes "may this be automated?" a lookup. DES-WORKER-ON-HOST amended (Accepted cost): up sequences the surrounding chores behind consent; the price — the worker is a host process the operator runs — is unchanged, only the typing shrank. DES-CLI-TRIGGER-FOR-LOCAL UNCHANGED, checked: up is not a producer; it enqueues nothing. INT-CONFIG-OVERLAY-CONTRACT UNCHANGED, checked: its repair-write precedent is cited by the fix-tier reasoning, not extended — --fix never rewrites an invalid overlay; that stays the admin write path's documented repair. |
| 2026-08-01 | DES-ADMIN-VIA-PI-EXTENSION amended (dashboard polish): run targets render as OSC-8 hyperlinks only when the URL is derivable from id-only fields (github repo#N; other forges' instance hosts are unknowable from the record, so no URL is ever guessed) — display-only escapes, byte-identical passthrough under the plain theme, and visibleLen already strips OSC-8. y/Y in RUN_DETAIL copy the job id / target URL via a new injected copyText seam whose OSC-52 emission lives in index.ts (the dashboard stays I/O-free); operator-initiated, id-only strings, nothing read back — recorded in SECURITY.md. LIVE_TAIL gains / search over the captured tail (a line-input layer above the view, popping on the established one-Esc-per-layer discipline; matches jump and suspend follow exactly as manual scrolling does; untrusted bytes still pass only through clip — the match highlight colors post-clip). The LIST and COSTS frames become height-aware through an injected terminalRows seam: sections collapse to their divider-plus-count by fixed priority (pause windows → settings → triggers → spend; COSTS: by-model → plans → daily), the cursor's section and the verdict block never collapse, and an absent seam renders byte-identically to before. The fs ban and every width invariant UNCHANGED, checked. |
| 2026-08-01 | DES-ADMIN-VIA-PI-EXTENSION amended (issue #53, REQ-COST-ANALYTICS): the overlay gains its fifth view, COSTS (c) — verdict-first analytics over one DES-COST-FOLD-BY-SCAN fold: per-plan verdicts with the API-rate comparison line, a daily sparkline, by-flow/by-model tables whose money cells all funnel through the typed-cost formatter, plan blocks with amortized $/run and peak-window facts (never burn-down), a provenance footer naming the pi-ai pin, and the keyboard what-if (w shortlist cycle; / type-to-filter over the full priced catalog via the line-input primitive — the long tail lives in the TUI now that the primitive exists, and in /dispatch costs whatif for scripting). The costs data path is lazy and throttled (view entry + window change + stale-tick refresh): the fold is cheap, but a per-second full-directory scan is the quiet load a dashboard must not add. /dispatch costs [7d|30d|mtd] renders the same fold plain for the degraded path; dispatch_costs returns it as JSON with class on every monetary value, so the model-facing surface cannot launder an estimate any more than the human-facing one. PI_DISPATCH_ASCII=1 flips every panel/overlay glyph table to the ASCII twins at extension load (the switch the primitives shipped; the env decision lives at the entry point, keeping panel.mjs pure). The dashboard's source-regex fs ban is UNCHANGED, checked — the costs data arrives through createDashboardDeps seams over the read-model, like every other byte the overlay renders. |
| 2026-08-01 | DES-ADMIN-VIA-PI-EXTENSION amended (issue #71, dashboard usability): the LIST runs list becomes a cursor-following 10-row viewport over the read model's 50-record window with ↑/↓ N more edge markers (raising the fetch from 10 to 50 without growing the frame); Tab jumps between the trigger and run section heads; o cycles the runs sort (time → tokens → cost → outcome — absent numbers sort last because a pre-metering record is unknown, not cheap, and Enter opens the row the sorted list shows because cursor and renderer share one rows model); the long-advertised-but-unbound l now opens the live tail of the active job and stays inert without one; LIVE_TAIL opens pinned to the bottom in follow mode (scroll-up pauses, bottom re-arms, footer names the state — it previously opened ~180 lines behind the head at the top of the tail window); RUN_DETAIL gains ←/→ in-place record walking with the LIST cursor following; a cron trigger row in LIST carries the amber ⚠ overdue/⚠ stalled badge previously visible only in TRIGGER_DETAIL; and x delete arms an in-frame y/n whose y alone signals deleteTrigger with confirmed: true, letting deleteTriggerEntry skip the duplicate ctx.ui.confirm while still writing through the shared validator — the dialog path is unchanged for the model-initiated dispatch_trigger_delete tool, whose confirmedWrite gate is UNCHANGED, checked. The fallback matchesKey in keys.mjs learned left/right/home/end/backspace so the overlays' new keys cannot be silently eaten when pi-tui is unresolvable. No new read-model surface, no new fs access; the dashboard's source-regex fs ban is UNCHANGED, checked. |
| 2026-08-01 | Added DES-COST-FOLD-BY-SCAN (issue #53, gap 4): cost aggregation is a read-only, filename-keyed scan of the run-history sidecars (scanRunRecords, listRuns' sibling without the 50-clamp; retention-bounded, hard-capped at 92 days even under keep-forever) folded by a pure fs-free admin/src/costs.mjs. The load-bearing decision: classification happens at fold time and is never stored — sidecars hold immutable facts, subscriptions/rates are opinions recomputed per fold, so editing subscriptions.json retroactively reclassifies history correctly. Every emitted dollar is a typed {usd, class, floor, coverage} value; one estimated addend demotes a sum visibly. Rejected, each with its reason: an embedded analytics store (re-refused under DES-RUN-HISTORY-FLAT-FILES-NO-DB); rollup/index files beside the sidecars (a second source of truth that goes stale on every sweep, retry overwrite, and subscriptions edit, to win milliseconds that were never being lost); a redis cost series beside budget:t:* (TTL'd enforcement state is not history); storing the classification on the record (a record written under one subscriptions file lies under the next). DES-RUN-HISTORY-FLAT-FILES-NO-DB UNCHANGED, checked and leaned on — the fold is exactly the bounded, not-a-query-surface scan that entry reserves. |
| 2026-08-01 | Added DES-SUBSCRIPTIONS-ARE-COUNTERFACTUAL-ONLY (issue #53): subscription plan prices live in an operator-authored subscriptions.json and feed counterfactual arithmetic only — never auth, routing, or model selection; the worker exports the shared validator and reads nothing at job time. Why: subscription-backed providers ship all-zero rate tables, so prepaid runs record cost 0 and read as free; the env boundary REFUSES subscription logins by design, making the operator declaration the only honest price source; and declaring a plan must never become a way to route to it. Rejected: vendor usage-API polling (a new network surface for numbers vendors barely publish), auto-detecting plans from zero-rate tables (a rate card is not a purchase), overlay keys instead of a file (the overlay is runtime tuning with fail-closed job-start semantics; prices are bookkeeping), and routing/auth integration (reopens the env-allowlist decision this design exists to respect). |
| 2026-08-01 | Added DES-REPLICA-INDEX-REACHES-THE-BRANCH (issue #56, REQ-REPLICA-RUNS): implement replica runs by threading ONE host-assigned integer through the four layers that collapse N attempts into one — the BullMQ job id, the semantic dedup key, the minted branch, and the prompt — and changing nothing else. The framing is deliberate: those layers are controls someone chose, not obstacles, and each stays exactly as strong for an unflagged run; making the discriminator one value with one owner is what lets the container name, PI_JOB_ID and the .log/.json sidecars become replica-distinct without being told. The branch is the load-bearing addition, and it lands in issueBranch because that function exists precisely so the prompt and the session key cannot each spell pi/issue-${n} — a replica adds a THIRD fact to that argument, namely that session-key.mjs calls it with one argument and may keep doing so only because triggers.mjs refuses replicas beside resume. Where the index deliberately STOPS is recorded as decision rather than omission: not the session key (the refusal already prevents the problem, and a second notion of which transcript a job continues would silently diverge), not event.json (an execution knob is not a fact about the delivery), not the budget (N reservations is the honest count). Rejected, with reasons: first-finished-wins with sibling cancellation (a half-cancelled run has already spent its tokens, so the saving is illusory and the comparison is destroyed); auto-judging the two pull requests (a third paid agent ranking two agents to save a human one diff read); an asymmetric scheme where replica 1 keeps pi/issue-<n> (it reads as an original and a copy, which is the framing that makes an operator stop comparing them); replicas for local/cron triggers (the shared working tree is a hazard, not a scope decision); a PI_REPLICA env var (the allowlist is closed by design and nothing in the container branches on the index — it would be a second place for it to disagree with the branch); a replica field in event.json; and deriving the cap from PI_CONCURRENCY at load (parseTriggers is pure and fs-free). DES-SESSION-KEY-IS-DERIVED-NOT-INDEXED UNCHANGED, checked: the key is still derived from what the job carries, and replicas add no index. DES-JOB-OUTBOX-CHAINING UNCHANGED, checked: the local-only guard already bounds fanout from a replica. |
| 2026-08-01 | Added DES-SANDBOX-IS-A-FRESH-CONTAINER (issue #55, REQ-RESURRECTABLE-SANDBOX): to let an operator inspect what a run built, retain the run's inputs and start a new container, never preserve the original. Six rejected alternatives recorded, and the first three are the ones that would otherwise be re-proposed — keeping the job container alive for docker exec, a stdin channel to the running agent, and docker commit snapshots. The first two reopen CONST-ISOLATION-CONTAINER-PER-JOB (a live container that has run adversarial code, still holding the minted token, with --rm removed); the third costs gigabytes per run to preserve mostly what belonged in the image. The other three are the smaller near-misses that each looked cheaper than the shipped answer and were not: publishing a port on job containers, editing the boot reaper's filter instead of using a disjoint name namespace, and widening makeLogReaper instead of adding a sibling. DES-RUN-HISTORY-FLAT-FILES-NO-DB UNCHANGED, and checked — the sandbox lookup is a filename-keyed read of one directory, adding no index and no query surface. |
| 2026-07-28 | The pi-normal discovery posture (CONST-NO-CONTEXT-FILES-MANDATORY, amended). DES-OPERATOR-GLOBAL-OVERLAY: overlay extensions are staged and loaded by default — --no-extensions is the escape hatch, every staged extension is printed by name, and PI_GLOBAL_ALLOW_EXTENSIONS survives inverted as the "0" opt-out — and staged packages load for every job except one whose trigger set run.packages: false. The "gated four times, not two" framing is restated honestly as three gates that refuse by default (exact pin, all-or-nothing host stage, runner pre-spend path check) plus one withdrawal, since the per-trigger switch now defaults open. The Rejected entry "load overlay extensions by default" is superseded rather than deleted: it is rewritten in place to record that this is what shipped first, that the arming flag sat behind two gates the operator had already passed, and that its failure mode was silent in the expensive direction — a present-but-dormant overlay is a deployment quietly missing the setup its flows were written against. The "copy ~/.pi wholesale" rejection lost its stale justification (it argued host-global discovery was off anyway) and now rests on the curated-subset argument, which is the one that was always doing the work. Two cross-references de-staled elsewhere in the file: DES-PERSONA-VIA-APPEND-SYSTEM-MD's Rejected AGENTS.md bullet said "forbidden" and now records that it loads but is rejected as the persona channel on placement (pi emits context files into <project_context> after the append block, so it can never be the floor); and DES-AI-TRIGGER-FLOW-GATE's trust-doctrine parenthetical, which cited the constraint as "a cloned repo's AGENTS.md … must not load", now cites it as amended — the doctrine it was actually appealing to, reading committed content at a fixed SHA rather than the live tree, is unchanged. DES-USAGE-METER-VIA-API-PROVIDER-REGISTRY was checked and needed no change: it asserts nothing about the discovery flags. |
| 2026-07-15 | Initial. Extracted from DESIGN.md v0.1 (2026-07-14, local, uncommitted) §2, §3, §4, §5, §9, §11. That document recorded "50 claims adversarially verified: 48 confirmed, 2 refuted" — verified against documentation. Source-verification at earendil-works/pi @ 5e336cf subsequently corrected ~7 points. DES-PERSONA-VIA-APPEND-SYSTEM-MD is materially rewritten: the source doc's decisions #1 and #2 were mutually exclusive as written. DES-NAME-KEEP-PI-DISPATCH is new. pi-harness and pi-sentry were absent from the source doc's alternatives and are added. §5.7's "caches roll at midnight" caveat is dropped — 0.80.7 removed the date from the default system prompt. |
| 2026-07-15 | An admin panel and cross-platform (Windows/macOS/Linux + Docker) added to scope. Two new decisions and one security correction. DES-PANEL-SEPARATE-FROM-RECEIVER: the source doc mounted Bull Board on the receiver — defensible for a read-only dashboard, not once the same surface sets the model and rewrites flows, because the receiver is the one process that must be internet-reachable. The panel and the receiver have opposite reachability requirements and cannot share a port. DES-FLOWS-ARE-DATA-PERSONA-IS-CODE: the panel requirement collided with keeping flows as reviewed repo markdown; resolved by observing that one file was carrying two jobs — hard rules need immutability, task recipes need editability. Architecture diagram and repo layout updated; the public edge is now drawn explicitly. Build order extended with panel and deploy. |
| 2026-07-16 | Resolved a spec/code contradiction. DES-WORKER-ON-HOST added and DES-JOB-FILES-VIA-VOLUME-SUBPATH marked SUPERSEDED: the worker runs on the host (the docker CLI translates host paths, the daemon does not, and the VM prefix moved between Docker Desktop versions; local-folder jobs also require a host bind mount a named volume cannot give). The committed spec had rejected worker-on-host while the code already did it -- caught by a spec-conformance scan. DES-CLI-TRIGGER-FOR-LOCAL added: the CLI producer was built (user-directed) but unspecified; recorded with the check that CONST-BUDGET-BEFORE-TOKENS still holds because the cap is enforced in the processor, not the trigger. Repo-layout deploy/ line corrected (compose runs Valkey only). |
| 2026-07-21 | Added DES-RUN-HISTORY-FLAT-FILES-NO-DB (flat node:fs sidecar over a DB / BullMQ-query for run history). |
| 2026-07-21 | DES-ADMIN-VIA-PI-EXTENSION injection-boundary bullet records the accepted residual: a prompt injection in the operator's session can invoke dispatch_pause/dispatch_resume, accepted as durable-but-reversible and money-safe (neither tool spends tokens nor raises the cap), so the worst case is an operator-observable, operator-undoable queue stall. |
| 2026-07-21 | DES-PANEL-SEPARATE-FROM-RECEIVER superseded by DES-ADMIN-VIA-PI-EXTENSION: the admin surface becomes a pi extension in the operator's own interactive session (slash commands + TUI overlay), binding no network port; Bull Board dropped. DES-RUNTIME-SETTINGS-FILE-OVERLAY added: a flat settings.json overlay (PI_SETTINGS_FILE), written atomically by the extension and re-read by the worker per job, precedence job.data > overlay > env > default, fail-closed on an invalid file before reserveBudget. Panel/dashboard wording cascaded across the architecture diagram, DES-QUEUE-BULLMQ-OVER-CUSTOM (four queue mechanisms, dashboard dropped), DES-CRON-VIA-BULLMQ-SCHEDULER, DES-CLI-TRIGGER-FOR-LOCAL, DES-WORKER-ON-HOST, DES-FLOWS-ARE-DATA-PERSONA-IS-CODE (flow editing deferred out of this slice), and the repo layout / build order. Paired with CONST-ISOLATION-CONTAINER-PER-JOB scoped to harness invocations in constitution.md. |
| 2026-07-22 | AI-triggered flows. Two new entries: DES-AI-TRIGGER-FLOW-GATE (a flow is AI-triggerable only if its .pi/skills/<flow>/SKILL.md frontmatter carries ai-trigger: allow, read from the git object store at the pre-agent SHA, default deny — an agent cannot self-authorize by committing its own SKILL.md) and DES-JOB-OUTBOX-CHAINING (an in-container agent writes request-<n>.json to a rw /outbox mount outside /workspace; the worker is the only enqueuer, collecting completed-only and forcing same-folder local jobs, VALKEY_URL never crossing the container boundary; GitHub-parent outboxes dropped). Two amendments: DES-CLI-TRIGGER-FOR-LOCAL now names three producers of local jobs (CLI, dispatch_run, outbox collector), retargets its superseded DES-PANEL-SEPARATE-FROM-RECEIVER trace to DES-ADMIN-VIA-PI-EXTENSION, and states the dirty-guard's same-folder-chain exception; DES-ADMIN-VIA-PI-EXTENSION adds a second named injection residual for the paid, not-money-safe dispatch_run tool (bounded by folder allowlist, per-flow opt-in, no-force, no spend-knob params, per-hour rate limit, daily cap), superseding the "reads plus pause/resume only" categorical. Reason: local jobs gain two prompt-injection-reachable producers, which need a WHAT-axis opt-in distinct from CONST-TRIGGER-AUTHOR-GATE's WHO-axis webhook gate. Companion requirements.md/interfaces.md/open-questions.md amendments land in sibling tasks. |
| 2026-07-22 | DES-JOB-OUTBOX-CHAINING records how the agent learns the outbox protocol: a separate baked persona file (guardrails/OUTBOX_PROTOCOL.md, immutable chmod a-w), composed into appendSystemPromptOverride only when /outbox is mounted (a github job is never billed for it) and evaluated once at loader build per CONST-PERSONA-IN-CACHED-PREFIX; kept out of HARD_RULES.md (the always-billed safety floor) and framed as documentation — the caps and ai-trigger gate are host-enforced, the persona controls nothing. |
| 2026-07-22 | Coherence fix: reworded the DES-ADMIN-VIA-PI-EXTENSION Decision line — "reads plus pause/resume only" now reads "reads, pause/resume, and the gated dispatch_run enqueue", resolving the self-contradiction with the same entry's second injection residual (every settings write stays operator-typed). |
| 2026-07-22 | DES-ADMIN-VIA-PI-EXTENSION dashboard amended to three in-component views — LIST (framed monochrome panel with unified TRIGGERS pane + ↑↓ runs selection), RUN_DETAIL (PII-free .json fields), and LIVE_TAIL — in one self-refreshing overlay. LIVE_TAIL renders raw .log bytes through an injected deps.tailLog seam whose fs read lives in index.ts, preserving the overlay-only .log boundary (never a tool result, never model context); USED_API stays the three pi members, tailLog being an internal custom-seam dependency, not a pi member. |
| 2026-07-28 | Issue #58. Added DES-USAGE-METER-VIA-API-PROVIDER-REGISTRY: token usage is metered at pi-ai's module-level api-provider registry — the one choke point every in-process session shares — instead of on a per-instance AgentSession bus that cannot see a subagent fanout, with the subscribe() accumulator kept as the fallback. Records the rejected alternatives (the subscribe-only meter, getSessionStats, undici/SSE parsing, an after_provider_response extension hook, patching pi) and the four things any implementation must handle, all found by runtime probe rather than by reading source: the dual pi-ai module instance, resetApiProviders() wiping raw registrations, wrapper displacement in both directions (identity tracking + a WeakSet of observed streams), and builtin-auth fidelity through the sibling-loaded fallback catalog. DES-OPERATOR-GLOBAL-OVERLAY amended: the overlay gains a packages tier (host-staged, exact-pinned, per-trigger armed, appended last to additionalExtensionPaths) and records the skill-ordering finding — pi puts package skill paths FIRST and loadSkills is first-path-wins, so on the raw load a staged skill beats the repo's, which would invert this entry's own "repo wins on conflict". Path order cannot fix it, but DefaultResourceLoaderOptions.skillsOverride (a declared option on the pinned loader, plus the public loadSkillsFromDir) can and does: precedence is re-imposed on the loaded result, repo before overlay before package, so the requirement holds by enforcement. Correction on the way in: an earlier draft of this row and entry said there was "no reordering lever" and resolved the finding by refusing the job — the premise was false and the refusal is gone; what remains is the collision report (visibility, and the tripwire that goes quiet if a future pi reorders skillPaths). Four new Rejected entries: a separate /opt/pi-packages:ro mount (would amend CONST-ISOLATION-CONTAINER-PER-JOB's enumerated acceptance for no capability the overlay lacks), a third env arming flag (redundant, and coarser than the per-trigger gate), routing packages through pi's settings.packages (would re-open the SettingsManager.inMemory protection), and npm: sources resolved in-container (a live network install of third-party code in an adversarial-input container, every run). |
| 2026-07-23 | DES-ADMIN-VIA-PI-EXTENSION amended for AI-operable, confirm-gated writes: the model-callable surface gains dispatch_triggers (read) and the write tools dispatch_set + dispatch_trigger_add/_edit/_delete, each routed through confirmedWrite — applied only after an operator approves a ctx.ui.confirm showing the concrete before/after, refused (writing nothing) when ctx.hasUI is false. Adds a third named injection residual bounded by that human confirm rather than by structure; supersedes the "every settings write is operator-typed, never a model tool" clause. Both CONST-BUDGET-BEFORE-TOKENS (check-before-tokens ordering) and CONST-TRIGGER-AUTHOR-GATE (webhook author-gating) are unchanged — the confirm is the human approval, and both write paths reach the same validated/atomic writeTriggers/writeSettings. Extension also ships an operate-pi-dispatch skill (advertised via resources_discover) recommending how to use the gates. USED_API gains on. Companion requirements.md/constitution.md amendments land with it. |
| 2026-07-29 | Issue #41. Added DES-PER-TRIGGER-JOB-IMAGE: the job image resolves per job (job.image ?? PI_JOB_IMAGE) from an optional operator-authored run.image, present on no model-callable tool, no panel key and not the settings overlay; a missing tag is refused pre-spend by docker image inspect and --pull=never joins ISOLATION_FLAGS. Rejected, with reasons on the record: PI_JOB_IMAGE_ALLOWLIST (the issue floats it — rejected because there is nothing model-callable to bound; PI_DISPATCH_RUN_ROOTS exists to bound a model-supplied folder, and an allowlist over a field only an operator can write can only refuse the operator's own edit while advertising a threat model this design forecloses — it arrives with the first tool that ever takes an image parameter, and that row is why it must); image in the runtime settings overlay; an image parameter on dispatch_trigger_add/_edit; a flow-declared image read from the serviced repo (the issue's second option — rejected hardest: that file is merge-gated, not operator-authored, and DES-AI-TRIGGER-FLOW-GATE takes only a boolean from it precisely because an image ref would hand that population the loader flags, the guardrail floor, the pinned pi version and the non-root user); a second mount or a job-time pull; and keeping every toolchain baked into one image. DES-RUNTIME-SETTINGS-FILE-OVERLAY is amended, not reversed: its Per-message env mutation rejection stands verbatim and image is not an exception to it — the overlay key list is unchanged and dispatch_set cannot set an image. The distinction is stated where it was previously only implied: that overlay is the admin-editable runtime channel (which is why its "never persona or hard rules" bar is scoped to it), while triggers.json is reviewed deploy-time operator config in the trust class REQ-GLOBAL-PI-OVERLAY calls "the same trust class as baking the image". DES-OPERATOR-GLOBAL-OVERLAY's "Bake the overlay into the image" rejection is UNCHANGED and was checked: nothing that was a mount becomes a bake, the overlay still rides :ro into whichever image runs, and the boundary is now written down — overlay = pi configuration, mounted, one per deployment; image = the operating system a flow needs, built, per flow. |
| 2026-07-29 | Issue #42. Added DES-FORGE-IS-A-PER-JOB-DEPENDENCY: a job's forge is resolved per job from job.kind at exactly one place — a forges map of { auth, host } in the worker's composition root — and the four deps that were bound to one forge (mintToken, comment, isDefaultBranchProtected, prepareWorkspace) look theirs up from the job. No abstraction was invented ahead of its second user: processor.mjs already consumed those four as independently injected functions rather than as one github object, so it was written against a de-facto interface and merely called it behind job.kind === "github" guards; the second forge revealed which parameters were wrong — a repo string where a job belonged — not that a new interface was needed. Rejected, with reasons on the record: a generic any-forge plugin framework; routing by header (Forgejo emits X-GitHub-* on every delivery per #61, so headers cannot tell forges apart — and a request able to select which gate it faced would select the weakest, where a path is chosen by the operator at configuration time and not by the sender at delivery time); negotiating the verification mechanism from the request, for the same reason one layer down; a filter that does its own membership lookup (both filters are pure, total and I/O-free, and that is exactly what makes the security-critical decision testable offline — so the lookup runs in the receiver and arrives as a plain number); adapting GitHub's 404-means-unprotected (#61 records the cost: every branch reports unprotected and the never-merge backstop is silently disarmed); inferring approval from label-application on GitLab; forking the clone path (the askpass helper, hardening flags, gone-SHA markers and pinned detached checkout are git and this project, not GitHub — only the remote URL and the envelope differ, and a second copy is a second place to fix a clone bug); and one shared postStatusComment(repo, number, …), since GitLab's issues and merge requests are separate endpoints AND separate sequences, and a host method that cannot be called uniformly is not a seam. DES-TRIGGERS-UNIFIED-FILE amended: the near-diagonal becomes cron ↔ local, webhook ↔ a forge. DES-PR-TRIGGER-ROUTES-TO-FLOW amended to say its target union is the shared vocabulary and not GitHub's — a GitLab merge request is a pull_request target carrying its iid, so the job shape does not fork per forge even though the two forges' nouns differ. DES-AI-TRIGGER-FLOW-GATE amended in one parenthetical that carried the broken premise into a WHO/WHAT passage. DES-JOB-OUTBOX-CHAINING generalised from kind:github to any forge kind — the adversarial-text reasoning was never GitHub-specific. DES-CRON-VIA-BULLMQ-SCHEDULER amended in one sentence for the same reason. DES-OPERATOR-GLOBAL-OVERLAY and DES-PER-TRIGGER-JOB-IMAGE are UNCHANGED and were checked: the overlay is a mount and the image is a tag, and neither is a property of which forge triggered the job — a gitlab trigger carries run.packages and run.image on exactly the same terms as a github one. |
| 2026-07-31 | Issue #48. NEW DES-SESSION-KEY-IS-DERIVED-NOT-INDEXED: which transcript a job resumes is computed from the job, never looked up. The issue proposed recording the session id and head branch and scanning back for the producing run; that is refused because an index is a query surface and a query surface is the database this file already declined. The derived key also makes the issue's own cross-repo/cross-PR safety ask unrepresentable rather than merely unlikely. Six rejected alternatives, and the one worth reading is keying on the pull-request number — forge-assigned and NOT attacker-chosen, so strictly better on the axis that matters, and useless anyway because nothing host-side joins issue #7 to the PR #8 its job opened without recording it, and recording it is the index. The branch is the only host-computable join; its name-forgeability is the price, paid in OQ-014. Also rejected: SessionManager.continueRecent, which scans a directory and resumes whatever ran last — one call, looks exactly right, and is the cross-author leak in its purest form. DES-RUN-HISTORY-FLAT-FILES-NO-DB UNCHANGED, and preserved deliberately rather than by luck: the derived key is what preserves it. DES-JOB-OUTBOX-CHAINING UNCHANGED, checked, and the comparison is written down because a reader who sees a second writable mount on a github job would otherwise conclude OQ-009 was resolved by the back door: /outbox lets a parent nominate host folders and enqueue paid jobs, /session returns bytes to one key, creates no job and names no host path. DES-PR-TRIGGER-ROUTES-TO-FLOW, DES-TRIGGERS-UNIFIED-FILE UNCHANGED, checked: no new trigger type and no new route ship here — run.resume is a run.* field on the four kinds that already exist. |
| 2026-07-31 | Issues #43 + #61. DES-FORGE-IS-A-PER-JOB-DEPENDENCY amended, and its own deferral is what this PR discharged: its Rejected list named #43 and #61 by number as the event a seam should be discovered from. They landed TOGETHER on purpose -- one example cannot show you a seam, and these two are the extremes of the space (Forgejo's transport is byte-identical to GitHub's and all its work is semantic; Azure shares almost nothing) so the shape was sized against both at once. What HELD without change: the { auth, host } pair, the four host methods, and makeForgePreparers, where a whole forge arm is five lines and two injections. What did NOT hold was everything written down elsewhere -- nine places said which forges exist, and the ones that mattered were the ones that failed silently: a missing receiver trigger group throws inside a reload that catches everything and keeps yesterday's rules, so an operator edits their file, sees one message, and the old rules go on firing; a missing token-variable name is simply not refused in PI_FORWARD_ENV, so a long-lived host token can be forwarded into every container of every forge. The answer is a TABLE those are derived from (worker/src/forges.mjs, which imports nothing so it can be the leaf of both services' graphs), not an interface for a forge to implement -- so the plugin-framework rejection still stands at four forges, now on evidence rather than on principle. Added DES-IMAGE-DECLARES-ITS-FORGES: run.image is optional, and Azure's CLI is ~1 GB of Python that belongs in a separate image variant, so a trigger that forgets run.image would fail at step 3 inside a paid container on every delivery. The image declares what it can serve and the pre-spend preflight refuses otherwise -- with the polarity written out, because it is the opposite of what "declare your capabilities" suggests: an absent label allows everything, since every operator-built image predating it declares nothing and refusing those would break working deployments with no warning. DES-TRIGGERS-UNIFIED-FILE and DES-PR-TRIGGER-ROUTES-TO-FLOW amended: two more forges in the matrix and the target vocabulary. DES-RUN-HISTORY-FLAT-FILES-NO-DB, DES-JOB-OUTBOX-CHAINING, DES-SESSION-KEY-IS-DERIVED-NOT-INDEXED, DES-PER-TRIGGER-JOB-IMAGE, DES-OPERATOR-GLOBAL-OVERLAY UNCHANGED, checked: no new forge introduces a query surface, a writable mount, or an index. The ASCII architecture diagram and the repo layout at the foot of this file still say "GitHub" where they mean "a forge" -- noted rather than fixed, because they were already stale after #42 and a drive-by rewrite would bury the two entries above. |