feat(specverb): transport-neutral engine core + inline-operation source, so ward-mcp (and any non-CLI consumer) drives the same spec/guard engine #196

Closed
opened 2026-07-07 07:36:53 +00:00 by coilysiren · 5 comments
Owner

Why

ward-mcp is adopting inline operation authoring (ward-mcp#6): a .mcp.kdl defines its operations inline (method / path-template / typed params), no vendored OpenAPI. Kai's directive is to push as much impl down into cli-guard as possible - ward-mcp stays a thin driver (MCP envelope + SSE transport + image build) and nothing more. Everything spec / guard / request-shaped is engine-general and belongs here, not re-grown in ward-mcp - the same code-in-the-wrong-layer problem ward#265 is unwinding for ward.

Two capabilities, both transport-neutral, land here so every consumer (the urfave/cli surface today, ward-mcp tomorrow) drives one engine.

1. Inline-operation source

Today specverb builds an operation descriptor {method, path, params, body} by resolving a grant against a vendored OpenAPI (L0). Add a second source: build the same descriptor from inline KDL - a tool (or grant) block carrying method + path-template + typed params with their location (path / query / body). No spec, no operationId, no op resolution - the descriptor is stated, not resolved, so the resolver / prune / lock machinery is simply not on this path.

Precedent: execverb already does exactly this shape for local commands (inline KDL, no spec). This extends the "author the surface inline" pattern to HTTP operations. Stays upstream-agnostic (the engine's defining constraint): the KDL carries generic HTTP-op structure, no vendor strings.

2. Transport-neutral descriptor surface

The engine already turns a descriptor into a guarded CLI leaf (the urfave/cli projection): validate typed args, run the restrict + metachar gates, assemble + sign the request, fire. Pull that core out from behind the CLI-specific projection and expose it transport-neutral, so a non-CLI consumer reuses it:

  • the operation's input JSON-schema (params → schema) - the CLI projection and an MCP-tool projection both need it;
  • a guarded-execute entrypoint (validated args → restrict/metachar gates → HTTP request → response), with no urfave/cli dependency.

The urfave/cli projection then becomes one consumer of the core; ward-mcp's MCP-tool projection is another. cli-guard stays MCP-agnostic - it exposes a schema + guarded execution, never MCP wire types - and ward-mcp owns only the MCP envelope + SSE transport.

The boundary (what does NOT come down)

Per cli-guard#190 (cli-guard grew an ops-orchestration engine it should not own), "push down" is not "everything down." This issue is the opposite of #190: it pulls the engine's actual core (parse → descriptor → guard → request) out from behind one projection so every consumer shares it. The line:

  • Comes down (engine-general): inline-op parsing, descriptor build, input-schema exposure, guarded request execution.
  • Stays in the consumer: transport wire protocols (MCP SSE, the urfave/cli surface), image build, orchestration/workflow.

Scope

  • specverb: inline-operation descriptor source (parse a tool block → descriptor), beside the existing OpenAPI source.
  • specverb / guardfile: expose the input JSON-schema + a transport-neutral guarded-execute entrypoint.
  • Keep the urfave/cli projection working on top - no CLI regression.
  • ward-mcp#6 - settles the inline .mcp.kdl grammar this parses (blocked-with; the grammar and this engine source co-design).
  • cli-guard#195 - the resolver nit, spec-backed path only (inline sidesteps operationIds entirely).
  • cli-guard#190 - the orchestration-out-of-scope boundary this respects.
  • ward#265 - the same slim-the-consumer discipline for ward; a transport-neutral engine core is the shared substrate that lets ward shed engine code too.
## Why ward-mcp is adopting inline operation authoring (ward-mcp#6): a `.mcp.kdl` defines its operations inline (method / path-template / typed params), no vendored OpenAPI. Kai's directive is to **push as much impl down into cli-guard as possible** - ward-mcp stays a thin driver (MCP envelope + SSE transport + image build) and nothing more. Everything spec / guard / request-shaped is engine-general and belongs here, not re-grown in ward-mcp - the same code-in-the-wrong-layer problem ward#265 is unwinding for ward. Two capabilities, both transport-neutral, land here so every consumer (the urfave/cli surface today, ward-mcp tomorrow) drives one engine. ## 1. Inline-operation source Today `specverb` builds an operation descriptor `{method, path, params, body}` by resolving a grant against a vendored OpenAPI (L0). Add a **second source**: build the same descriptor from **inline KDL** - a `tool` (or grant) block carrying method + path-template + typed params with their location (path / query / body). No spec, no operationId, no `op` resolution - the descriptor is *stated*, not resolved, so the resolver / prune / lock machinery is simply not on this path. Precedent: `execverb` already does exactly this shape for local commands (inline KDL, no spec). This extends the "author the surface inline" pattern to HTTP operations. Stays upstream-agnostic (the engine's defining constraint): the KDL carries generic HTTP-op structure, no vendor strings. ## 2. Transport-neutral descriptor surface The engine already turns a descriptor into a guarded CLI leaf (the urfave/cli projection): validate typed args, run the restrict + metachar gates, assemble + sign the request, fire. **Pull that core out from behind the CLI-specific projection** and expose it transport-neutral, so a non-CLI consumer reuses it: - the operation's **input JSON-schema** (params → schema) - the CLI projection and an MCP-tool projection both need it; - a **guarded-execute entrypoint** (validated args → restrict/metachar gates → HTTP request → response), with no urfave/cli dependency. The urfave/cli projection then becomes one consumer of the core; ward-mcp's MCP-tool projection is another. cli-guard stays **MCP-agnostic** - it exposes a schema + guarded execution, never MCP wire types - and ward-mcp owns only the MCP envelope + SSE transport. ## The boundary (what does NOT come down) Per cli-guard#190 (cli-guard grew an ops-orchestration engine it should not own), "push down" is **not** "everything down." This issue is the opposite of #190: it pulls the engine's *actual core* (parse → descriptor → guard → request) out from behind one projection so every consumer shares it. The line: - **Comes down** (engine-general): inline-op parsing, descriptor build, input-schema exposure, guarded request execution. - **Stays in the consumer**: transport wire protocols (MCP SSE, the urfave/cli surface), image build, orchestration/workflow. ## Scope - `specverb`: inline-operation descriptor source (parse a `tool` block → descriptor), beside the existing OpenAPI source. - `specverb` / `guardfile`: expose the input JSON-schema + a transport-neutral guarded-execute entrypoint. - Keep the urfave/cli projection working on top - no CLI regression. ## Related - **ward-mcp#6** - settles the inline `.mcp.kdl` grammar this parses (blocked-with; the grammar and this engine source co-design). - **cli-guard#195** - the resolver nit, spec-backed path only (inline sidesteps operationIds entirely). - **cli-guard#190** - the orchestration-out-of-scope boundary this respects. - **ward#265** - the same slim-the-consumer discipline for ward; a transport-neutral engine core is the shared substrate that lets ward shed engine code too.
Member

🔎 ward agent advisor

ward agent advisor --driver claude ran a one-shot standard research pass on this question:

Design the concrete Go factoring to land cli-guard#196, grounded in cli-guard's actual packages (specverb, guardfile, the urfave/cli projection, and execverb as the inline-authoring precedent). Do NOT change code - post the design as a comment on the issue.

Deliver:

  1. Transport-neutral core - the package/type layout to pull specverb's engine core (parse -> descriptor -> guard -> request) out from behind the urfave/cli projection. Specifically: what type exposes an operation's input JSON-schema (params -> schema) and what is the guarded-execute entrypoint (validated args -> restrict + metachar gates -> signed HTTP request -> response) with NO urfave/cli dependency. Name the packages/interfaces and where they live.
  2. Inline-operation source - how a descriptor gets built from inline KDL (the ward-mcp .mcp.kdl 'can { path ...; query ...; body ...; set ... }' grammar frozen in ward-mcp#6, with method inferred from the verb and path params inferred from the {template}) slotting BESIDE the existing OpenAPI source that feeds the same descriptor. Mirror how execverb authors commands inline. What parses it, what it produces.
  3. No CLI regression - how the existing urfave/cli projection re-consumes the extracted core so the ward-kdl guardfile CLIs are unchanged.
  4. The boundary - confirm what stays OUT per cli-guard#190 (no orchestration comes down, only the engine core).
  5. A recommended split into buildable engineer-sized commits/PRs, and any real factoring fork worth Kai deciding before implementation.

Public-safe, repo voice rules (no em-dashes, no semicolons in prose, name the actor).


cli-guard#196 - concrete Go factoring for a transport-neutral engine core + inline-operation source

Grounded in the actual packages: the descriptor is http/specverb.opDescriptor (http/specverb/specverb.go:51), the request runtime is http/specverb.runtime (http/specverb/request.go:34), the guard gates are runtime.checkRestrictions (http/specverb/restrict.go:15) plus the shell-metachar gate in cli/verb.Wrap (cli/verb/verb.go:90), the fire path is runtime.fireCapture (http/specverb/request.go:515), and the inline-authoring precedent is cli/execverb (its own Parse at cli/execverb/guardfile.go:155 producing an exec Guardfile, no spec).

The decisive constraint from the issue is the neutral entrypoint carries no urfave/cli dependency. specverb imports github.com/urfave/cli/v3 (http/specverb/specverb.go:15), so a consumer that imports specverb transitively drags urfave/cli in. That rules out a facade-on-specverb and points at a real package split.


1. Transport-neutral core - new package http/opcore

Stand up a new urfave/cli-free package (http/opcore, name is Kai's call - http/httpop / http/operation are alternatives) holding the engine's actual core: parse-agnostic descriptor, the guard gates, and the request machinery. Move, do not copy:

  • opcore.Descriptor - the exported opDescriptor: Method, Path, PathParams, Query/Body/Form fields, FixedBody, plus identity (Name, Destructive, Grant, Describe). Today's opDescriptor (http/specverb/specverb.go:51) and fieldFlag (:69) move here as Descriptor and Field.
  • opcore.Runtime - the exported runtime (http/specverb/request.go:34): auth, baseURL/baseURLValue, client, providers, restrict. It brings its methods with it: checkRestrictions (restrict gate), authorize + resolveChain (sign), baseForRequest, the argBinder (http/specverb/action.go:561), fillPath/assembleQuery/assembleBody/assembleMultipart, and fireCapture (fire, no render). None of these touch urfave/cli today - they are reached through actionFor(*cli.Command), which is the only coupling to break.
  • The input JSON-schema surface - opcore.Schema built from a Descriptor:
type Schema struct {
    Properties map[string]Property // keyed by field name
    Required   []string
}
type Property struct {
    Type        string // string|boolean|integer|number|array
    Items       string // element type when Type==array
    Location    string // path|query|body|form (neutral hint)
    Description string
}
func (d Descriptor) InputSchema() Schema      // PathParams + Query/Body/Form -> schema
func (s Schema) JSONSchema() []byte           // optional draft-07 object emitter

Path params are required strings at location=path, query/body/form fields lower exactly as fieldFlagsToCLI already reads them. cli-guard stays MCP-agnostic: it emits generic JSON-schema, never an MCP tool type.

  • The guarded-execute entrypoint - opcore.Operation = Descriptor bound to a *Runtime:
type Operation struct { Desc Descriptor; RT *Runtime }
type Args struct { Path map[string]string; Query map[string]string; Body map[string]any }
type Response struct { Decoded any; Raw []byte; Status string }
func (o Operation) InputSchema() Schema
func (o Operation) Execute(ctx context.Context, a Args) (Response, error)

Execute runs, in order: the shell-metachar gate over url-bound args (pkg/policy.ValidateArgs/ValidateArgSlice, the exact functions cli/verb.Wrap calls today at cli/verb/verb.go:92), then checkRestrictions, then argBinder assembly (path/query/body), baseForRequest, authorize, fireCapture. It returns the response and renders nothing. Execute is self-guarding - a consumer with no verb.Wrap around it is still gated, which is the whole point for MCP.

What Execute deliberately omits: response rendering (respfmt --query/--output), the --dry-run pretty-print, and audit. Those are consumer projections. A neutral Operation.Preview(Args) Request can expose the resolved-but-unfired request so each projection renders its own dry-run.

One shared table to factor out in the same move: the verb-to-method mapping. verbConventions (http/specverb/resolve.go) carries method + path-shape for OpenAPI resolution. The inline source (below) needs the method half. Expose a small opcore.MethodForVerb(verb) (string, bool) so both sources share one closed verb table.


2. Inline-operation source - beside the OpenAPI source

Today the only Descriptor source is resolveDescriptor (http/specverb/specverb.go:391): grant -> resolveOp -> spec.findOp -> descriptor, riding the whole resolver/prune/lock stack. Add a second source that states the descriptor instead of resolving it, mirroring how execverb.Parse authors exec commands inline with no spec.

Because ward-mcp needs this source and must not pull urfave/cli, the inline parser is also urfave/cli-free - it lives in http/opcore (or a sibling http/inlineop), not in specverb. Putting it in specverb would re-couple MCP to the CLI projection, defeating the issue.

// http/opcore (or http/inlineop)
func ParseInline(src []byte) ([]Descriptor, RuntimeConfig, error)

Parsing the ward-mcp#6 grammar frozen as can <verb> <resource> { path "..."; query "..."; body "..."; set k=v }:

  • Uses the same KDL library (github.com/calico32/kdl-go) and the same node-walking shape as guardfile.parseGrant / execverb.parseGrant.
  • Method inferred from the verb via opcore.MethodForVerb (the shared table): create->POST, get/view->GET, list->GET, edit->PATCH/PUT, delete->DELETE, the state toggles and membership verbs already enumerated in verbConventions.
  • Path params inferred from the {template} via the existing pathParamsInOrder(path) / pathParamRe (http/specverb/request.go:303 region) - no operationId, no op resolution.
  • query "a" "b" -> Field{Location: query}, body "x" "y" -> body Fields, set state="closed" -> FixedBody (the same KDL-native RawValue() path guardfile uses at http/guardfile/guardfile.go:688).
  • Reuses checkFlagCollisions (http/specverb/specverb.go:433) so an inline descriptor fails closed on a reserved-flag clash exactly like a resolved one.

Output is a slice of the same opcore.Descriptor the OpenAPI source feeds, so every downstream projection (CLI leaf, MCP tool, schema, guarded execute) is source-blind. Upstream-agnostic by construction: the KDL carries generic HTTP-op structure ({method-from-verb, path-template, typed params}), no vendor strings, no operationIds.


3. No CLI regression - the urfave/cli projection re-consumes the core

specverb stays the CLI projection and one consumer of opcore. After the move it keeps: OpenAPI parse (swagger.go, openapi3.go, prune*), resolution (resolve.go, wildcard.go, deny.go), the actions engine (action*.go), describe/surface, and Build/Mount. Changes are mechanical:

  • resolveDescriptor now returns an opcore.Descriptor (a type alias type opDescriptor = opcore.Descriptor keeps every existing reference compiling through the transition).
  • buildLeaf (http/specverb/request.go:89) is unchanged in shape: same fieldFlagsToCLI flags, same universal --dry-run/--query/--output, same rt.wrap(verb.Spec{...}) so the metachar + audit pipeline (cli/verb.Wrap) runs exactly as today.
  • actionFor (http/specverb/request.go:176) becomes a thin adapter: read positional path args + set flags from *cli.Command (existing extraction), build an opcore.Args, call Operation.Execute, then render with respfmt honoring --query/--output, and route --dry-run through Operation.Preview. Same inputs, same URL, same fired request - the ward-kdl guardfile CLIs are byte-identical.
  • The actions machinery keeps calling the moved runtime methods (buildCallRequest, fireCapture) unchanged - they are now opcore.Runtime methods reached from specverb.

Belt-and-suspenders on the gate: verb.Wrap already runs the metachar gate for CLI leaves, and Execute re-runs it - both call pkg/policy, so the semantics are one implementation, idempotent when stacked. Keep both so a CLI regression is impossible and a non-CLI caller is still gated.


4. The boundary - what stays OUT (cli-guard#190)

#196 pulls the core out from behind one projection. It must not pull orchestration down - that is the mistake #190 is unwinding. The line the agent holds:

  • Comes down (engine-general, into opcore) - descriptor, input-schema exposure, the restrict + metachar gates, request assembly + signing + fire, and the inline-op parser. Plus the OpenAPI source staying in specverb.
  • Stays in the consumer - the actions engine (poll / call / collect / canary / rollback in action*.go) is a CLI-side consumer of the core, calling Execute per step. It is not exported as engine-general. The MCP SSE envelope, the urfave/cli surface, image build, response rendering (respfmt --output plumbing), and OpenAPI vendoring + operationId resolution all stay put. If ward-mcp ever wants orchestration, that is a separate decision, never a silent consequence of this issue.

  • PR1 - extract http/opcore with zero behavior change. Move opDescriptor->Descriptor, fieldFlag->Field, runtime->Runtime, plus request.go (argBinder, fillPath, assemble*, authorize, resolveChain, fireCapture, baseForRequest) and restrict.go. Add Operation.Execute (gates + assemble + fire, no render) and repoint specverb.actionFor and the actions engine at it. Keep type opDescriptor = opcore.Descriptor aliases so the diff is mechanical and every existing specverb and execverb test stays green. Load-bearing, unblocks the rest.
  • PR2 - Descriptor.InputSchema() + Schema/Property (+ optional JSONSchema() emitter) in opcore, with tests. Independent after PR1. specverb describe can optionally consume it.
  • PR3 - inline-operation source (ParseInline) for the ward-mcp#6 grammar, plus factoring MethodForVerb out of verbConventions. Tests over the frozen grammar (method-from-verb, path-params-from-template, set->FixedBody, collision fail-closed). Depends on PR1, co-designs with ward-mcp#6.
  • PR4 (ward-mcp repo, downstream, not cli-guard) - ward-mcp imports opcore (+ inline source): builds its MCP tool list from InputSchema, dispatches Execute on tool invocation, owns only the MCP envelope + SSE + image build. Tracked in ward-mcp, blocked-with this. Not filed here - it lands after the cli-guard engine work and against ward-mcp's own tree.

Factoring forks worth Kai deciding before implementation

  • Package boundary - move vs facade. Recommend the move into a urfave/cli-free http/opcore. A facade exported from specverb leaves the transitive urfave/cli dep, which the issue's neutral-entrypoint constraint forbids. Cost is a larger PR1 diff, bought back by aliases.
  • Where the inline parser lives. Recommend opcore (or a sibling http/inlineop), not specverb, for the same coupling reason. Naming and same-package-vs-sibling is Kai's call.
  • Does the actions engine move too? Recommend no - it stays in specverb as a consumer, honoring #190. Only revisit if ward-mcp is meant to drive orchestration, which is out of scope here.
  • Schema emitter shape. Recommend the neutral Schema struct now plus a thin draft-07 JSONSchema() bytes emitter, keeping cli-guard MCP-agnostic. The MCP tool wrapper stays in ward-mcp.
  • Args shape. Recommend reusing argBinder's existing name-routing (it already routes a flat name onto path/query/body at http/specverb/action.go:593) so one binder serves both the CLI and MCP projections, rather than a second typed-splitting path.

This is single-repo work - the whole engine factoring lands in cli-guard, and the one cross-repo piece (ward-mcp consuming the core) is already tracked at ward-mcp#6 and co-designs the grammar. No fan-out needed.


Researched and posted automatically by ward agent advisor --driver claude (ward#179). This is one-shot research, not a carried change - verify before acting on it.

— Claude (she/her), via ward agent

### 🔎 ward agent advisor `ward agent advisor --driver claude` ran a one-shot **standard** research pass on this question: > Design the concrete Go factoring to land cli-guard#196, grounded in cli-guard's actual packages (specverb, guardfile, the urfave/cli projection, and execverb as the inline-authoring precedent). Do NOT change code - post the design as a comment on the issue. > > Deliver: > 1. **Transport-neutral core** - the package/type layout to pull specverb's engine core (parse -> descriptor -> guard -> request) out from behind the urfave/cli projection. Specifically: what type exposes an operation's input JSON-schema (params -> schema) and what is the guarded-execute entrypoint (validated args -> restrict + metachar gates -> signed HTTP request -> response) with NO urfave/cli dependency. Name the packages/interfaces and where they live. > 2. **Inline-operation source** - how a descriptor gets built from inline KDL (the ward-mcp .mcp.kdl 'can <verb> <resource> { path ...; query ...; body ...; set ... }' grammar frozen in ward-mcp#6, with method inferred from the verb and path params inferred from the {template}) slotting BESIDE the existing OpenAPI source that feeds the same descriptor. Mirror how execverb authors commands inline. What parses it, what it produces. > 3. **No CLI regression** - how the existing urfave/cli projection re-consumes the extracted core so the ward-kdl guardfile CLIs are unchanged. > 4. **The boundary** - confirm what stays OUT per cli-guard#190 (no orchestration comes down, only the engine core). > 5. A recommended split into buildable engineer-sized commits/PRs, and any real factoring fork worth Kai deciding before implementation. > > Public-safe, repo voice rules (no em-dashes, no semicolons in prose, name the actor). --- ## cli-guard#196 - concrete Go factoring for a transport-neutral engine core + inline-operation source Grounded in the actual packages: the descriptor is `http/specverb.opDescriptor` (`http/specverb/specverb.go:51`), the request runtime is `http/specverb.runtime` (`http/specverb/request.go:34`), the guard gates are `runtime.checkRestrictions` (`http/specverb/restrict.go:15`) plus the shell-metachar gate in `cli/verb.Wrap` (`cli/verb/verb.go:90`), the fire path is `runtime.fireCapture` (`http/specverb/request.go:515`), and the inline-authoring precedent is `cli/execverb` (its own `Parse` at `cli/execverb/guardfile.go:155` producing an exec `Guardfile`, no spec). The decisive constraint from the issue is **the neutral entrypoint carries no urfave/cli dependency**. `specverb` imports `github.com/urfave/cli/v3` (`http/specverb/specverb.go:15`), so a consumer that imports `specverb` transitively drags urfave/cli in. That rules out a facade-on-specverb and points at a real package split. --- ## 1. Transport-neutral core - new package `http/opcore` Stand up a new urfave/cli-free package (`http/opcore`, name is Kai's call - `http/httpop` / `http/operation` are alternatives) holding the engine's actual core: parse-agnostic descriptor, the guard gates, and the request machinery. Move, do not copy: - **`opcore.Descriptor`** - the exported `opDescriptor`: `Method`, `Path`, `PathParams`, `Query`/`Body`/`Form` fields, `FixedBody`, plus identity (`Name`, `Destructive`, `Grant`, `Describe`). Today's `opDescriptor` (`http/specverb/specverb.go:51`) and `fieldFlag` (`:69`) move here as `Descriptor` and `Field`. - **`opcore.Runtime`** - the exported `runtime` (`http/specverb/request.go:34`): `auth`, `baseURL`/`baseURLValue`, `client`, `providers`, `restrict`. It brings its methods with it: `checkRestrictions` (restrict gate), `authorize` + `resolveChain` (sign), `baseForRequest`, the `argBinder` (`http/specverb/action.go:561`), `fillPath`/`assembleQuery`/`assembleBody`/`assembleMultipart`, and `fireCapture` (fire, no render). None of these touch urfave/cli today - they are reached through `actionFor(*cli.Command)`, which is the only coupling to break. - **The input JSON-schema surface** - `opcore.Schema` built from a `Descriptor`: ```go type Schema struct { Properties map[string]Property // keyed by field name Required []string } type Property struct { Type string // string|boolean|integer|number|array Items string // element type when Type==array Location string // path|query|body|form (neutral hint) Description string } func (d Descriptor) InputSchema() Schema // PathParams + Query/Body/Form -> schema func (s Schema) JSONSchema() []byte // optional draft-07 object emitter ``` Path params are required strings at `location=path`, query/body/form fields lower exactly as `fieldFlagsToCLI` already reads them. cli-guard stays **MCP-agnostic**: it emits generic JSON-schema, never an MCP tool type. - **The guarded-execute entrypoint** - `opcore.Operation` = `Descriptor` bound to a `*Runtime`: ```go type Operation struct { Desc Descriptor; RT *Runtime } type Args struct { Path map[string]string; Query map[string]string; Body map[string]any } type Response struct { Decoded any; Raw []byte; Status string } func (o Operation) InputSchema() Schema func (o Operation) Execute(ctx context.Context, a Args) (Response, error) ``` `Execute` runs, in order: the **shell-metachar gate** over url-bound args (`pkg/policy.ValidateArgs`/`ValidateArgSlice`, the exact functions `cli/verb.Wrap` calls today at `cli/verb/verb.go:92`), then `checkRestrictions`, then `argBinder` assembly (path/query/body), `baseForRequest`, `authorize`, `fireCapture`. It returns the response and renders nothing. `Execute` is **self-guarding** - a consumer with no `verb.Wrap` around it is still gated, which is the whole point for MCP. What `Execute` deliberately omits: response rendering (`respfmt` --query/--output), the `--dry-run` pretty-print, and audit. Those are consumer projections. A neutral `Operation.Preview(Args) Request` can expose the resolved-but-unfired request so each projection renders its own dry-run. One shared table to factor out in the same move: the verb-to-method mapping. `verbConventions` (`http/specverb/resolve.go`) carries method + path-shape for OpenAPI resolution. The inline source (below) needs the method half. Expose a small `opcore.MethodForVerb(verb) (string, bool)` so both sources share one closed verb table. --- ## 2. Inline-operation source - beside the OpenAPI source Today the only `Descriptor` source is `resolveDescriptor` (`http/specverb/specverb.go:391`): grant -> `resolveOp` -> `spec.findOp` -> descriptor, riding the whole resolver/prune/lock stack. Add a **second source** that states the descriptor instead of resolving it, mirroring how `execverb.Parse` authors exec commands inline with no spec. Because ward-mcp needs this source and must not pull urfave/cli, the inline parser is **also** urfave/cli-free - it lives in `http/opcore` (or a sibling `http/inlineop`), **not** in `specverb`. Putting it in `specverb` would re-couple MCP to the CLI projection, defeating the issue. ```go // http/opcore (or http/inlineop) func ParseInline(src []byte) ([]Descriptor, RuntimeConfig, error) ``` Parsing the ward-mcp#6 grammar frozen as `can <verb> <resource> { path "..."; query "..."; body "..."; set k=v }`: - Uses the same KDL library (`github.com/calico32/kdl-go`) and the same node-walking shape as `guardfile.parseGrant` / `execverb.parseGrant`. - **Method inferred from the verb** via `opcore.MethodForVerb` (the shared table): `create->POST`, `get`/`view->GET`, `list->GET`, `edit->PATCH/PUT`, `delete->DELETE`, the state toggles and membership verbs already enumerated in `verbConventions`. - **Path params inferred from the `{template}`** via the existing `pathParamsInOrder(path)` / `pathParamRe` (`http/specverb/request.go:303` region) - no operationId, no `op` resolution. - `query "a" "b"` -> `Field{Location: query}`, `body "x" "y"` -> body `Field`s, `set state="closed"` -> `FixedBody` (the same KDL-native `RawValue()` path `guardfile` uses at `http/guardfile/guardfile.go:688`). - Reuses `checkFlagCollisions` (`http/specverb/specverb.go:433`) so an inline descriptor fails closed on a reserved-flag clash exactly like a resolved one. Output is a slice of the **same** `opcore.Descriptor` the OpenAPI source feeds, so every downstream projection (CLI leaf, MCP tool, schema, guarded execute) is source-blind. Upstream-agnostic by construction: the KDL carries generic HTTP-op structure (`{method-from-verb, path-template, typed params}`), no vendor strings, no operationIds. --- ## 3. No CLI regression - the urfave/cli projection re-consumes the core `specverb` stays the CLI projection and one consumer of `opcore`. After the move it keeps: OpenAPI parse (`swagger.go`, `openapi3.go`, `prune*`), resolution (`resolve.go`, `wildcard.go`, `deny.go`), the actions engine (`action*.go`), `describe`/surface, and `Build`/`Mount`. Changes are mechanical: - `resolveDescriptor` now returns an `opcore.Descriptor` (a type alias `type opDescriptor = opcore.Descriptor` keeps every existing reference compiling through the transition). - `buildLeaf` (`http/specverb/request.go:89`) is unchanged in shape: same `fieldFlagsToCLI` flags, same universal `--dry-run`/`--query`/`--output`, same `rt.wrap(verb.Spec{...})` so the metachar + audit pipeline (`cli/verb.Wrap`) runs exactly as today. - `actionFor` (`http/specverb/request.go:176`) becomes a thin adapter: read positional path args + set flags from `*cli.Command` (existing extraction), build an `opcore.Args`, call `Operation.Execute`, then render with `respfmt` honoring `--query`/`--output`, and route `--dry-run` through `Operation.Preview`. Same inputs, same URL, same fired request - the ward-kdl guardfile CLIs are byte-identical. - The actions machinery keeps calling the moved runtime methods (`buildCallRequest`, `fireCapture`) unchanged - they are now `opcore.Runtime` methods reached from `specverb`. Belt-and-suspenders on the gate: `verb.Wrap` already runs the metachar gate for CLI leaves, and `Execute` re-runs it - both call `pkg/policy`, so the semantics are one implementation, idempotent when stacked. Keep both so a CLI regression is impossible and a non-CLI caller is still gated. --- ## 4. The boundary - what stays OUT (cli-guard#190) #196 pulls the *core* out from behind one projection. It must not pull orchestration down - that is the mistake #190 is unwinding. The line the agent holds: - **Comes down (engine-general, into `opcore`)** - descriptor, input-schema exposure, the restrict + metachar gates, request assembly + signing + fire, and the inline-op parser. Plus the OpenAPI source staying in `specverb`. - **Stays in the consumer** - the actions engine (poll / call / collect / canary / rollback in `action*.go`) is a **CLI-side consumer** of the core, calling `Execute` per step. It is not exported as engine-general. The MCP SSE envelope, the urfave/cli surface, image build, response rendering (`respfmt` --output plumbing), and OpenAPI vendoring + operationId resolution all stay put. If ward-mcp ever wants orchestration, that is a separate decision, never a silent consequence of this issue. --- ## 5. Recommended commit / PR split - **PR1 - extract `http/opcore` with zero behavior change.** Move `opDescriptor`->`Descriptor`, `fieldFlag`->`Field`, `runtime`->`Runtime`, plus `request.go` (argBinder, fillPath, assemble*, authorize, resolveChain, fireCapture, baseForRequest) and `restrict.go`. Add `Operation.Execute` (gates + assemble + fire, no render) and repoint `specverb.actionFor` and the actions engine at it. Keep `type opDescriptor = opcore.Descriptor` aliases so the diff is mechanical and every existing `specverb` and `execverb` test stays green. Load-bearing, unblocks the rest. - **PR2 - `Descriptor.InputSchema()` + `Schema`/`Property` (+ optional `JSONSchema()` emitter)** in `opcore`, with tests. Independent after PR1. `specverb describe` can optionally consume it. - **PR3 - inline-operation source** (`ParseInline`) for the ward-mcp#6 grammar, plus factoring `MethodForVerb` out of `verbConventions`. Tests over the frozen grammar (method-from-verb, path-params-from-template, `set`->FixedBody, collision fail-closed). Depends on PR1, co-designs with ward-mcp#6. - **PR4 (ward-mcp repo, downstream, not cli-guard)** - ward-mcp imports `opcore` (+ inline source): builds its MCP tool list from `InputSchema`, dispatches `Execute` on tool invocation, owns only the MCP envelope + SSE + image build. Tracked in ward-mcp, blocked-with this. Not filed here - it lands after the cli-guard engine work and against ward-mcp's own tree. ### Factoring forks worth Kai deciding before implementation - **Package boundary - move vs facade.** Recommend the **move** into a urfave/cli-free `http/opcore`. A facade exported from `specverb` leaves the transitive urfave/cli dep, which the issue's neutral-entrypoint constraint forbids. Cost is a larger PR1 diff, bought back by aliases. - **Where the inline parser lives.** Recommend `opcore` (or a sibling `http/inlineop`), **not** `specverb`, for the same coupling reason. Naming and same-package-vs-sibling is Kai's call. - **Does the actions engine move too?** Recommend **no** - it stays in `specverb` as a consumer, honoring #190. Only revisit if ward-mcp is meant to drive orchestration, which is out of scope here. - **Schema emitter shape.** Recommend the neutral `Schema` struct now plus a thin draft-07 `JSONSchema()` bytes emitter, keeping cli-guard MCP-agnostic. The MCP tool wrapper stays in ward-mcp. - **`Args` shape.** Recommend reusing `argBinder`'s existing name-routing (it already routes a flat name onto path/query/body at `http/specverb/action.go:593`) so one binder serves both the CLI and MCP projections, rather than a second typed-splitting path. This is single-repo work - the whole engine factoring lands in cli-guard, and the one cross-repo piece (ward-mcp consuming the core) is already tracked at ward-mcp#6 and co-designs the grammar. No fan-out needed. --- Researched and posted automatically by `ward agent advisor --driver claude` (ward#179). This is one-shot research, not a carried change - verify before acting on it. <!-- ward-agent-reply --> <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Author
Owner

Decisions (Kai, 2026-07-07): package name http/opcore; the advisor factoring is accepted (move-not-facade is forced by the urfave/cli import constraint; actions engine stays in specverb per #190; reuse argBinder; neutral Schema + JSONSchema emitter). Split into three sequential cli-guard PRs, all landing here:

  • #197 (PR1) - extract http/opcore (Descriptor + Runtime + self-guarding Operation.Execute), zero behavior change, aliases keep tests green. Dispatched headless. Load-bearing.
  • #198 (PR2) - Descriptor.InputSchema() + neutral Schema/Property + JSONSchema emitter. Blocked on #197.
  • #199 (PR3) - ParseInline inline-op source for the ward-mcp#6 grammar + MethodForVerb. Blocked on #197, co-designs ward-mcp#6.

Downstream: ward-mcp#7 (runtime) consumes opcore, ward-mcp#8 (chart). This issue stays the design/tracking parent.

**Decisions (Kai, 2026-07-07):** package name **`http/opcore`**; the advisor factoring is accepted (move-not-facade is forced by the urfave/cli import constraint; actions engine stays in specverb per #190; reuse `argBinder`; neutral `Schema` + JSONSchema emitter). Split into three sequential cli-guard PRs, all landing here: - **#197 (PR1)** - extract `http/opcore` (Descriptor + Runtime + self-guarding `Operation.Execute`), zero behavior change, aliases keep tests green. **Dispatched headless.** Load-bearing. - **#198 (PR2)** - `Descriptor.InputSchema()` + neutral Schema/Property + JSONSchema emitter. Blocked on #197. - **#199 (PR3)** - `ParseInline` inline-op source for the ward-mcp#6 grammar + `MethodForVerb`. Blocked on #197, co-designs ward-mcp#6. Downstream: ward-mcp#7 (runtime) consumes opcore, ward-mcp#8 (chart). This issue stays the design/tracking parent.
Member

🔒 Reserved by ward agent --harness codex — container engineer-codex-cli-guard-196 on host kais-macbook-pro-2.local is carrying this issue (reserved 2026-07-08T17:09:23Z). Concurrent ward agent runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); --force overrides.

Do not comment on or edit this issue to steer the run while it is reserved. The engineer seeded the body once at launch and never re-reads it, so a comment or edit reaches only human readers, never the running engineer. A correction goes to a new issue, dispatched fresh — that is the only channel that reaches a run in flight. Where the forge supports it, ward locks this conversation to make that a road-block rather than a convention (ward#494).

run seed context — what this run is carrying (ward#609)
  • Resolved: coilyco-flight-deck/cli-guard#196 · branch issue-196 · harness codex · workflow direct-main
  • Run: engineer-codex-cli-guard-196 · ward v0.451.0 · dispatched 2026-07-08T17:09:23Z
  • Comment thread: 2 included in the pre-flight read, 0 stripped (ward's own automated comments).

Issue body as seeded:

## Why

ward-mcp is adopting inline operation authoring (ward-mcp#6): a `.mcp.kdl` defines its operations inline (method / path-template / typed params), no vendored OpenAPI. Kai's directive is to **push as much impl down into cli-guard as possible** - ward-mcp stays a thin driver (MCP envelope + SSE transport + image build) and nothing more. Everything spec / guard / request-shaped is engine-general and belongs here, not re-grown in ward-mcp - the same code-in-the-wrong-layer problem ward#265 is unwinding for ward.

Two capabilities, both transport-neutral, land here so every consumer (the urfave/cli surface today, ward-mcp tomorrow) drives one engine.

## 1. Inline-operation source

Today `specverb` builds an operation descriptor `{method, path, params, body}` by resolving a grant against a vendored OpenAPI (L0). Add a **second source**: build the same descriptor from **inline KDL** - a `tool` (or grant) block carrying method + path-template + typed params with their location (path / query / body). No spec, no operationId, no `op` resolution - the descriptor is *stated*, not resolved, so the resolver / prune / lock machinery is simply not on this path.

Precedent: `execverb` already does exactly this shape for local commands (inline KDL, no spec). This extends the "author the surface inline" pattern to HTTP operations. Stays upstream-agnostic (the engine's defining constraint): the KDL carries generic HTTP-op structure, no vendor strings.

## 2. Transport-neutral descriptor surface

The engine already turns a descriptor into a guarded CLI leaf (the urfave/cli projection): validate typed args, run the restrict + metachar gates, assemble + sign the request, fire. **Pull that core out from behind the CLI-specific projection** and expose it transport-neutral, so a non-CLI consumer reuses it:

- the operation's **input JSON-schema** (params → schema) - the CLI projection and an MCP-tool projection both need it;
- a **guarded-execute entrypoint** (validated args → r

… (truncated to 2000 chars; full body is on this issue)

Static container doctrine and seed boilerplate are identical every run and omitted here (they ride ward v0.451.0).

— Codex, via ward agent

<!-- ward-agent-reservation --> 🔒 Reserved by `ward agent --harness codex` — container `engineer-codex-cli-guard-196` on host `kais-macbook-pro-2.local` is carrying this issue (reserved 2026-07-08T17:09:23Z). Concurrent `ward agent` runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); `--force` overrides. **Do not comment on or edit this issue to steer the run while it is reserved.** The engineer seeded the body once at launch and never re-reads it, so a comment or edit reaches only human readers, never the running engineer. A correction goes to a **new issue, dispatched fresh** — that is the only channel that reaches a run in flight. Where the forge supports it, ward locks this conversation to make that a road-block rather than a convention (ward#494). <details><summary>run seed context — what this run is carrying (ward#609)</summary> - **Resolved:** `coilyco-flight-deck/cli-guard#196` · branch `issue-196` · harness `codex` · workflow `direct-main` - **Run:** `engineer-codex-cli-guard-196` · ward `v0.451.0` · dispatched `2026-07-08T17:09:23Z` - **Comment thread:** 2 included in the pre-flight read, 0 stripped (ward's own automated comments). - included: @coilyco-ops (2026-07-07T08:22:01Z), @coilysiren (2026-07-07T08:28:32Z) **Issue body as seeded:** ``` ## Why ward-mcp is adopting inline operation authoring (ward-mcp#6): a `.mcp.kdl` defines its operations inline (method / path-template / typed params), no vendored OpenAPI. Kai's directive is to **push as much impl down into cli-guard as possible** - ward-mcp stays a thin driver (MCP envelope + SSE transport + image build) and nothing more. Everything spec / guard / request-shaped is engine-general and belongs here, not re-grown in ward-mcp - the same code-in-the-wrong-layer problem ward#265 is unwinding for ward. Two capabilities, both transport-neutral, land here so every consumer (the urfave/cli surface today, ward-mcp tomorrow) drives one engine. ## 1. Inline-operation source Today `specverb` builds an operation descriptor `{method, path, params, body}` by resolving a grant against a vendored OpenAPI (L0). Add a **second source**: build the same descriptor from **inline KDL** - a `tool` (or grant) block carrying method + path-template + typed params with their location (path / query / body). No spec, no operationId, no `op` resolution - the descriptor is *stated*, not resolved, so the resolver / prune / lock machinery is simply not on this path. Precedent: `execverb` already does exactly this shape for local commands (inline KDL, no spec). This extends the "author the surface inline" pattern to HTTP operations. Stays upstream-agnostic (the engine's defining constraint): the KDL carries generic HTTP-op structure, no vendor strings. ## 2. Transport-neutral descriptor surface The engine already turns a descriptor into a guarded CLI leaf (the urfave/cli projection): validate typed args, run the restrict + metachar gates, assemble + sign the request, fire. **Pull that core out from behind the CLI-specific projection** and expose it transport-neutral, so a non-CLI consumer reuses it: - the operation's **input JSON-schema** (params → schema) - the CLI projection and an MCP-tool projection both need it; - a **guarded-execute entrypoint** (validated args → r ``` … (truncated to 2000 chars; full body is on this issue) Static container doctrine and seed boilerplate are identical every run and omitted here (they ride ward v0.451.0). </details> <!-- ward-agent-signature --> — Codex, via `ward agent`
Member

WARD-OUTCOME: done - live leaf assembly now resolves through opcore.
The split held up better than I expected once I kept the dry-run planner local. The only snag was the planner's $ref placeholders, which do not belong in the live request resolver, so I left that path on the existing specverb assembly. Confidence is good. The remaining rough edge is the multipart leaf path, which still follows the older specverb code.

WARD-OUTCOME: done - live leaf assembly now resolves through opcore. The split held up better than I expected once I kept the dry-run planner local. The only snag was the planner's `$ref` placeholders, which do not belong in the live request resolver, so I left that path on the existing specverb assembly. Confidence is good. The remaining rough edge is the multipart leaf path, which still follows the older specverb code.
Member

PR-BODY-NOTE: ADVISORY-ONLY REVIEW: no heterogeneous reviewer family was available besides the worker (codex), so the adversarial panel could not run and did NOT gate this diff. Dropped: opencode (unavailable: opencode not on PATH); codex (worker's own family - never reviews its own diff). A human should review this change with the extra scrutiny an unrun panel would have applied.

PR-BODY-NOTE: ADVISORY-ONLY REVIEW: no heterogeneous reviewer family was available besides the worker (codex), so the adversarial panel could not run and did NOT gate this diff. Dropped: opencode (unavailable: opencode not on PATH); codex (worker's own family - never reviews its own diff). A human should review this change with the extra scrutiny an unrun panel would have applied.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
coilyco-flight-deck/cli-guard#196
No description provided.