ADR 047: CLI Invocation Context — transmit the resolved app and environment with every call¶
Status: Draft Type: Feature (breaking — one coordinated CLI+server release) Created: 2026-06-04 Related-ADRs: 036 (§D7 implicit app resolution), 042 (resolution chains, project contexts), a future command-manifest ADR (plugin command manifest)
This decision supersedes the client-side app-scoped injection mechanism (hop3_cli/core/app_scope.py), a stopgap retired by the design below.
Context¶
The stopgap and why it drifts¶
The CLI must decide before the RPC round-trip whether a command operates on a single app, so it can resolve an implicit app (ADR 036 §D7, ADR 042 §Resolution chains) and inject it. It makes that decision from a hardcoded set[tuple[str, ...]] in core/app_scope.py, and on a match injects the resolved app as the first positional argument. The module is a stopgap pending a future command-manifest ADR.
The drift it causes is a recurring class of bug: the hardcoded set falls out of sync with the server's command registry, so a renamed or added command silently stops resolving its implicit app. A representative case is hop3 app status demanding an explicit <app_name> while its siblings (app ping, app logs, …) resolve one, because the set still lists the renamed app show and is missing app status.
The set is a hand-maintained copy of a property that actually lives server-side (each Command either needs an app or doesn't). So it drifts silently whenever a command is renamed or added, and the failure is quiet: the command still runs, it just stops resolving the implicit app and falls back to "Usage: … <app_name>". The same disease appears in two sibling hardcoded sets — commands/destructive.py::DESTRUCTIVE_COMMANDS and main.py::_MISMATCH_GUARDED_PREFIXES — and the whole approach structurally cannot cover plugin commands (a future command-manifest ADR), which the CLI has never heard of.
The constraint that makes "just ask the server per command" a non-starter is real: app resolution happens before any round-trip, must work offline (--why, error messages), must not add latency to every call, and must work before authentication (auth login cannot query a gated endpoint to learn it is not app-scoped).
The reframing¶
The drift comes from the CLI needing to know, per command, whether to inject the app. Invert it: the CLI always resolves the ambient app (and the rest of the resolved environment) and ships it as a side-channel invocation context on every call. Each server command consumes context.app only if it wants one. The CLI no longer needs to know which commands are app-scoped — that knowledge stays entirely server-side, where it belongs — and the wire channel already exists.
The wire channel already exists¶
The cli RPC already carries a side dict (extra_args) alongside the positional args: get_extra_args (CLI) populates repository, env_vars, streaming, verbosity; the server's call(command_name, args, extra_args) already treats some keys as context, not command kwargs — it pops verbosity ("extracted as context") and injects _token. The invocation context is the same pattern, generalized into one reserved sub-structure.
Decision¶
Add a single invocation context object, resolved client-side once per command and transmitted with every CLI→server call. The server treats it as ambient context (never a raw command kwarg) and fills a command's app parameter from it on demand. The client-side app-scoped set is retired from its injection role.
The context object¶
A JSON object under a reserved key in extra_args (proposed: _context, mirroring _token):
{
"_context": {
"app": "ac-sciences", // resolved ambient app, or null
"app_source": "hop3.toml [metadata].id at /…/ac-sciences", // for --why and §D14
"server": "prod", // resolved server name (ADR 042), or null
"context": "prod", // resolved project context name, or null
"cwd": "/Users/…/ac-sciences", // operator's working directory
"cwd_app_id": "ac-sciences", // [metadata].id at/above cwd (for §D14), or null
"cli_version": "0.6.0"
}
}
The object is open for extension ("may be useful for other things"): future fields (locale, output width, dry-run intent, trace id) ride here without a protocol change. Unknown fields are ignored by older code that doesn't read them — but see Versioning on the reserved-key requirement.
Values are still produced by ADR 042's resolution chains; nothing about how the app/server/context resolve changes. ADR 042 stays authoritative for the resolution model (servers, project contexts, the resolution order); this ADR is authoritative only for how the resolved values travel to the server and are consumed there. What changes is that resolution is always performed and always transmitted, rather than gated on a hardcoded app-scoped check and injected positionally.
Server-side consumption¶
The RPC dispatch pops _context (exactly as it pops verbosity/_token today — it never reaches command.call(**kwargs)). Then, for the command being dispatched:
- If the command's
call/runsignature declares anapp(orapp_name) parameter and no app was supplied positionally, fill it from_context.app. - An explicit positional/flag app always wins over the ambient context.
- A command with no app parameter ignores
_context.appentirely — so a command that is not app-scoped can never accidentally pick up the ambient app.
The server already introspects command signatures for an app parameter (server/cli/cli.py does this for the argparse CLI: "add the app argument if the command has an App parameter"). That same introspection becomes the single source of truth for app-scoped-ness, consumed by the RPC path.
What this retires¶
core/app_scope.py's injection role disappears: the CLI stops maintaining a set to decide whether to inject, because it always resolves and transmits. A command that needs an app and didn't get one (no positional, empty _context.app) errors server-side with the same structured "no app resolved — here's how to fix" message (ADR 036 §D10), which can be sourced from _context.app_source's trace.
Whether the destructive-confirmation set (DESTRUCTIVE_COMMANDS) and the §D14 mismatch guard (_MISMATCH_GUARDED_PREFIXES) also fold into this model is an open question (below) — they are related (both are per-command metadata the CLI hardcodes) but distinct concerns (they gate client-side prompting, which still needs to happen before the round-trip).
Bonus: the §D14 mismatch guard gets cleaner¶
The ADR 042 §D14 guard compares the resolved app against the CWD project's [metadata].id. With _context carrying app, app_source, cwd, and cwd_app_id together, the check has everything it needs in one structure — and could move server-side (refuse the destructive RPC) so the guarantee holds regardless of which client issued it.
Rejected alternatives¶
Keep positional injection, source app-scoped-ness from a cached server manifest¶
The server exposes a machine-readable command manifest (name, app_scoped, destructive, …); the CLI caches it (the completion --refresh cache + cached_subcommand_index() already do exactly this for aliases) and derives the injection decision from the cache instead of a hardcoded set. This fixes drift and covers plugins, and keeps positional injection unchanged.
Rejected as the primary mechanism because it still requires the CLI to make a per-command decision (and keep a cache fresh) to do something the server could just do from the context. The invocation context is simpler: the CLI ships ambient state unconditionally and the consumer decides. The manifest remains a good complement for the client-side concerns the context can't cover (destructive prompting, did-you-mean), and is the natural surface for a future command-manifest ADR.
Per-command round-trip ("ask the server if this needs an app")¶
Rejected: adds latency and a network dependency to every command, breaks offline use and --why, and is impossible before authentication.
Do nothing (keep the hardcoded set, patched)¶
Acceptable short-term, guarded by patching as drift is found. Rejected as the end state because drift is silent and recurring, and plugins can't be covered.
Open questions¶
- Reserved-key name and shape.
_context(underscore = reserved/context param, like_token) vs a flatter set of_-prefixed keys. Nested is more extensible; flat is simpler to pop. - Eager vs lazy resolution. Resolving the ambient app walks the filesystem (and possibly git) on every invocation. Cheap, but pointless for purely-local commands (
version,settings) that never reach the server. Gate resolution on "command will hit the server", or accept the cost for uniformity? - Do destructive-confirmation and the §D14 guard fold in here, into a manifest, or stay hardcoded? They need client-side per-command knowledge (to prompt before sending), which the context alone does not provide.
- Server-side §D14. Move the mismatch refusal server-side (using
_context), or keep it client-side with richer context? - Trust.
_contextis client-supplied. The server must treatcwd,cwd_app_id, etc. as untrusted hints (fine for UX and the mismatch guard, never for authorization). Confirm no security decision keys off context fields.
Versioning and migration¶
The CLI and server roll out together in one coordinated release (consistent with ADR 042's brutally-relentless stance): the CLI starts sending _context and the server starts popping and consuming it in lockstep.
The reserved key must be popped by the server dispatch before command.call(**kwargs); an unknown extra_args key would otherwise reach the command as a kwarg and raise TypeError, so an old server cannot silently tolerate a new CLI's _context. Mixed old-server / new-client is therefore explicitly unsupported. New-server / old-client keeps working: the server fills app from a positional (as before) when _context is absent, so the dispatch must treat a missing context as "no ambient app", not an error.
Related ADRs: ADR 036: CLI Ergonomics and Command Surface, ADR 042: CLI Context Model — Servers and Project Contexts