2026-08-29 · Updated 2026-08-29 · 12 min read
Safe explicit cross-project task routing
The explicit routing contract for agent task boards that span repositories: an opt-in per-project allowlist, one validated user registry, exact process replacement into the destination wrapper, and refusals that never fall back to the source board.
By Juno AI INC · yylo-ledger · routing · multi-repo
The moment one operator runs agents in more than one repository, a new class of work appears: a regression triaged inside repository A's agent session, but owned and fixed on repository B's board. Moving that work is not the hard part. The hard part is what a task tool does when the operator does *not* say where it should go. Most answers to that question — a global default project, an ambient pointer variable exported hours ago for a different purpose, a directory walk that grabs the nearest board — share one property: the command succeeds, and the state lands somewhere nobody chose. This page documents the released contract that replaces every one of those answers in YYLO Ledger: routing by an explicit alias, an allowlist the source project opts into, one registry of validated targets, and an execution boundary that swaps the calling process for the destination project's own wrapper. Every refusal, payload, and transcript below was provoked in a live sandbox on 2026-08-29 driving the release of yylo-ledger pinned in this site's package facts; sandbox paths and timestamps belong to that recorded session, and what holds across sessions is the behavior itself.
Why silent fallback corrupts cross-project task state
A board earns its authority by being one repository's truth — the property the git-native task guide establishes for a single project. Cross-project work attacks exactly that property. Consider the write that goes wrong quietly: an operator in the wrong shell tab creates the task; the tool resolves a board from ambient state; the command exits zero; the task now lives on a board that never owned it, invisible to the people who should triage it, blocking a dependency graph it was never part of. The mirror image is worse — a status mutation intended for tonight's board landing on last month's, because an environment variable still pointed there. Neither failure announces itself. Diagnosis means diffing two histories for a change nobody intended to make, and the tooling that caused it reports success the whole time.
The design law that follows is short: a cross-project write must be deliberate or refused, never guessed. Deliberate means the operator names the destination on the command that mutates it. Refused means that when any link in the chain — policy, registration, target, environment — is missing or stale, the command halts with a diagnosis instead of choosing a fallback board. The shipped contract's first sentence is the default state of the whole system: "Cross-project access is disabled by default." The implementation module carries the same posture as its one-line charter — "Opt-in user registry and exact process routing for cross-project Kanban commands." — and everything below is that charter unpacked.
The allowlist the source project opts into
Policy lives where an auditor would look first: the source project's own .juno_task/config.json, in one section. It has exactly two keys — enabled and allowedProjects, a list of aliases this project may reach — and nothing else is accepted; the recorded session added an unexpected key and the tool answered Project registry error: invalid kanbanRegistry: unknown keys: unexpectedKey with exit three. Enablement is visible without leaking destinations: project status prints {"enabled": false, "source": "default"} for an unconfigured project — no aliases, no paths — and its parser help describes the intent precisely: "Show registry enablement without exposing disabled paths". A configured project answers {"allowedProjects": ["alpha"], "enabled": true, "source": "project-config"}.
Two environment variables can override the file — and the shipped rule is that "Environment policy has precedence over project config", which the status payload confirms by reporting "source": "environment" when YYLO_LEDGER_REGISTRY_ENABLED and YYLO_LEDGER_REGISTRY_ALLOWED_PROJECTS re-enable routing over a disabled config. Precedence does not mean leniency: a value the parser cannot read refuses the command — Project registry error: invalid YYLO_LEDGER_REGISTRY_ENABLED: expected true or false — and so does an empty alias in the list, with invalid YYLO_LEDGER_REGISTRY_ALLOWED_PROJECTS: empty project alias. The allowlist is enforced at route time, not advisory: with alpha allowed and beta registered but absent from the list, routing to beta answers Project registry error: project alias is not allowed by source project: beta, exit three, nothing executed. An enabled project with an empty list refuses every alias, which the README states as a design sentence worth quoting whole: "Enabling without an allowlist grants access to nothing."
The default state — a source project that has configured nothing — refuses before any registry is consulted. Recorded verbatim, with stdout empty and the exit status on the final line:
Aliases themselves carry a grammar — one to sixty-four characters, lowercase letters, digits, underscore, hyphen — and violations are refused at every entry point: Project registry error: invalid project alias: expected 1-64 lowercase letters, digits, '_' or '-'. A tight grammar is not pedantry here; it is what makes an alias safe to type, to review in a pipeline, and to diff in a policy file.
One registry, validated targets
Destinations live in a single per-user registry — ~/.yylo-ledger/projects.json, relocatable with YYLO_LEDGER_REGISTRY_PATH — and the shipped instruction for it is: "Register initialized local projects in the single user registry (~/.yylo-ledger/projects.json) and route any read or write explicitly". Registration is one command, and it validates before it stores. project add alpha --path /path/to/projA accepts a target only if it exists, holds a .juno_task directory, and carries an executable .juno_task/scripts/kanban.sh — the same wrapper routing later executes. The recorded session provoked every refusal the add-time validator can give, each with exit three: a nonexistent path answered Project registry error: registered target no longer exists: /nonexistent/x: [Errno 2] No such file or directory: '/nonexistent'; a plain directory without .juno_task answered Project registry error: target is not an initialized Juno project: /private/tmp/seo071/notproj; and a .juno_task tree with no executable wrapper answered Project registry error: target Kanban wrapper is missing or not executable: /private/tmp/seo071/projC/.juno_task/scripts/kanban.sh. The successful registration returned the full entry, timestamps included:
{"alias": "alpha", "createdAt": "2026-08-29T07:29:48.011058Z", "path": "/private/tmp/seo071/projA", "updatedAt": "2026-08-29T07:29:48.011058Z"}
Management stays deliberately small. The recorded multi-entry list answered with every alias in sorted order, each entry carrying the same four fields; show returns one; remove deletes one, and removing an alias that is already gone answers Project registry error: project alias is not registered: beta. Re-registering a live alias refuses rather than overwrites — Project registry error: project alias is already registered: alpha; use --replace to change it — so a path silently changing under a stable name must be an explicit act, and the recorded --replace kept createdAt at the original timestamp while moving updatedAt forward. The registry document itself is schema-versioned and shape-checked on every read; corrupting it to {"projects": {"alpha": {"path": "relative"}}} makes the next command answer Project registry error: project registry is malformed: expected schemaVersion and projects. Storage hygiene is source-documented rather than re-derived here: the file is written 0600 inside a 0700 directory, mutations hold an exclusive flock with a two-second timeout, and writes land through an atomic replace followed by an fsync — and the recorded registry file shows the sorted-key, schema-version-one document those rules produce.
Registration is a binding, not a promise. The two staleness drills — a registered directory moved away, a registered wrapper renamed — both happened *after* a successful project add, and both refused at route time: Project registry error: registered target no longer exists: /private/tmp/seo071/projA: [Errno 2] No such file or directory: '/private/tmp/seo071/projA' and Project registry error: target Kanban wrapper is missing or not executable: /private/tmp/seo071/projA/.juno_task/scripts/kanban.sh. Validation re-runs on every route, which is what makes a stale entry a stopped command rather than a silent local write.
Routing is exact process replacement
The operator names the destination with one global flag whose help line states the whole mechanism: "Route this command through an allowed registered project wrapper". The flag is extracted from the argument vector before anything is parsed and before standard input is read, which has three recorded consequences. First, position does not matter — --project alpha create --tags bug, create --tags bug --project alpha, and the --project=alpha form all routed identically in the session, because the extractor walks the raw arguments rather than a parsed grammar. Second, the flag is deliberately single-use: two routes on one command answer Project registry error: --project may be specified only once, and a missing alias Project registry error: --project requires an alias. Third, piped input survives untouched: routing a create whose body arrives on stdin delivered the bytes to the destination wrapper byte-for-byte, because the source process consumed nothing before handing the stream on.
What happens at a successful route is the docstring's eight words: "Replace this process with the destination wrapper, preserving stdio exactly." The calling process does not shell out and wait; it is *replaced*, and the recorded session pinned every leg of that replacement by routing into a wrapper that prints its own world:
Read those lines as the contract. The wrapper is the invoked program now, standing in the destination root, holding exactly the forwarded command. The environment has been scrubbed across the boundary: the ambient task-root variable, both Python path variables, and the virtualenv marker are unset — and the session proved the scrub against a live hazard by exporting a root variable pointing at a *different* board before routing; the destination still saw <unset>, so ambient state cannot redirect a routed write. The source virtualenv's bin directory is stripped from PATH as well. Two variables survive the boundary going the other direction: the destination's invocation root, and a hop marker that exists to refuse a second hop — a routed wrapper that tries to route again answers Project registry error: cross-project Kanban routing recursion rejected, and the session recorded the full chain: a destination wrapper whose own body routed onward stopped with exactly this refusal and exit three. Because the process is replaced rather than wrapped, exit status is the destination's own: the drill wrapper exited forty-two on a marker argument and the shell observed exactly 42.
The README names why the boundary is drawn this way: "Routing executes the destination project's .juno_task/scripts/kanban.sh, so its controller, .venv_juno, compatibility checks, stdin rules, and write guards remain authoritative." The destination's runtime — not the caller's — governs everything after the handoff. A wrapper that pins the two things worth pinning, executed as printed in the recorded session, is the whole operator-side ceremony:
Run as kx --project alpha create --tags bug with the regression body piped in, it produced the identity block above, exit zero. The wrapper pins the registry location so ambient pointers cannot change the target, then delegates; every routing guarantee still belongs to the tool underneath.
Every failure refuses; none falls back
The refusal catalog from the recorded session, each one exit three — the configuration exit, in the ladder the shell-and-JSON pipeline guide documents in full — and each one a stopped command with empty stdout:
- disabled source project, before any registry read: "cross-project Kanban registry is disabled in the source project"
- alias outside the allowlist: "project alias is not allowed by source project: beta"
- alias never registered: "project alias is not registered: self"
- target directory gone after registration: "registered target no longer exists: …"
- target no longer a project, or its wrapper gone or unexecutable: "target is not an initialized Juno project: …" / "target Kanban wrapper is missing or not executable: …"
- alias that resolves back to the source project itself: "cross-project alias resolves to the source project: self"
- a second hop, detected by the marker the first hop set: "cross-project Kanban routing recursion rejected"
- the flag misused: "may be specified only once" / "requires an alias"
- malformed storage at any layer — registry bytes, source config, policy variables — each refused with a parseable reason naming what it rejected.
The class the errors share is named by its own docstring: "A fail-closed registry configuration, storage, or routing error." And the summary sentence in the README is the one to cite: "Missing, stale, malformed, disallowed, or recursive routes fail without falling back to the source board." That last clause is the anti-corruption guarantee. A refusal is loud, attributable, and cheap to retry after fixing the actual cause; a fallback is silent, lands state in the wrong place, and reports success while doing it. The alternative designs this catalog forecloses are exactly the ones from the opening section — the tool never picks a board on the operator's behalf, so it can never pick the wrong one.
The pattern to reuse
Strip the specifics and five properties remain, each independently worth copying into any multi-project agent tooling:
1. Name the destination explicitly, per command. Routing is an argument on the mutating command, not a session mode that persists after the operator forgets it. 2. Make refusal the default. A source project with no policy, or an enabled policy with no aliases, routes nowhere. 3. Validate targets twice — at registration and again on every route — so stale bindings stop commands instead of redirecting them. 4. Cross the boundary by process replacement with a scrubbed environment. The destination's own runtime stays authoritative; ambient variables from the caller cannot steer it. 5. Fail terminally, never locally. Every failure exits with a diagnosis and writes nothing to the source board.
The README states the boundary's rationale in one line — "selecting a foreign storage path with the caller runtime could bypass project-specific safety" — and backs the mechanism with its own test claim: "Two-project subprocess tests verify the implementation preserves exact stdin and reaches only the selected target." For the current operational surface, start from the YYLO documentation — the operations hub that links each product reference. If this routing methodology or the recorded refusal evidence is useful in your own design work, cite or reuse it with attribution; that is what this page is for.