feat(specverb): transport-neutral engine core + inline-operation source, so ward-mcp (and any non-CLI consumer) drives the same spec/guard engine #196
Labels
No labels
burndown-2026-06
sunday-sprint
coherence-core
consult
headless
interactive
P0
P1
P2
P3
P4
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
coilyco-flight-deck/cli-guard#196
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Why
ward-mcp is adopting inline operation authoring (ward-mcp#6): a
.mcp.kdldefines 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
specverbbuilds 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 - atool(or grant) block carrying method + path-template + typed params with their location (path / query / body). No spec, no operationId, noopresolution - the descriptor is stated, not resolved, so the resolver / prune / lock machinery is simply not on this path.Precedent:
execverbalready 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 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:
Scope
specverb: inline-operation descriptor source (parse atoolblock → descriptor), beside the existing OpenAPI source.specverb/guardfile: expose the input JSON-schema + a transport-neutral guarded-execute entrypoint.Related
.mcp.kdlgrammar this parses (blocked-with; the grammar and this engine source co-design).🔎 ward agent advisor
ward agent advisor --driver clauderan a one-shot standard research pass on this question: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 ishttp/specverb.runtime(http/specverb/request.go:34), the guard gates areruntime.checkRestrictions(http/specverb/restrict.go:15) plus the shell-metachar gate incli/verb.Wrap(cli/verb/verb.go:90), the fire path isruntime.fireCapture(http/specverb/request.go:515), and the inline-authoring precedent iscli/execverb(its ownParseatcli/execverb/guardfile.go:155producing an execGuardfile, no spec).The decisive constraint from the issue is the neutral entrypoint carries no urfave/cli dependency.
specverbimportsgithub.com/urfave/cli/v3(http/specverb/specverb.go:15), so a consumer that importsspecverbtransitively 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/opcoreStand up a new urfave/cli-free package (
http/opcore, name is Kai's call -http/httpop/http/operationare alternatives) holding the engine's actual core: parse-agnostic descriptor, the guard gates, and the request machinery. Move, do not copy:opcore.Descriptor- the exportedopDescriptor:Method,Path,PathParams,Query/Body/Formfields,FixedBody, plus identity (Name,Destructive,Grant,Describe). Today'sopDescriptor(http/specverb/specverb.go:51) andfieldFlag(:69) move here asDescriptorandField.opcore.Runtime- the exportedruntime(http/specverb/request.go:34):auth,baseURL/baseURLValue,client,providers,restrict. It brings its methods with it:checkRestrictions(restrict gate),authorize+resolveChain(sign),baseForRequest, theargBinder(http/specverb/action.go:561),fillPath/assembleQuery/assembleBody/assembleMultipart, andfireCapture(fire, no render). None of these touch urfave/cli today - they are reached throughactionFor(*cli.Command), which is the only coupling to break.opcore.Schemabuilt from aDescriptor:Path params are required strings at
location=path, query/body/form fields lower exactly asfieldFlagsToCLIalready reads them. cli-guard stays MCP-agnostic: it emits generic JSON-schema, never an MCP tool type.opcore.Operation=Descriptorbound to a*Runtime:Executeruns, in order: the shell-metachar gate over url-bound args (pkg/policy.ValidateArgs/ValidateArgSlice, the exact functionscli/verb.Wrapcalls today atcli/verb/verb.go:92), thencheckRestrictions, thenargBinderassembly (path/query/body),baseForRequest,authorize,fireCapture. It returns the response and renders nothing.Executeis self-guarding - a consumer with noverb.Wraparound it is still gated, which is the whole point for MCP.What
Executedeliberately omits: response rendering (respfmt--query/--output), the--dry-runpretty-print, and audit. Those are consumer projections. A neutralOperation.Preview(Args) Requestcan 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 smallopcore.MethodForVerb(verb) (string, bool)so both sources share one closed verb table.2. Inline-operation source - beside the OpenAPI source
Today the only
Descriptorsource isresolveDescriptor(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 howexecverb.Parseauthors 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 siblinghttp/inlineop), not inspecverb. Putting it inspecverbwould re-couple MCP to the CLI projection, defeating the issue.Parsing the ward-mcp#6 grammar frozen as
can <verb> <resource> { path "..."; query "..."; body "..."; set k=v }:github.com/calico32/kdl-go) and the same node-walking shape asguardfile.parseGrant/execverb.parseGrant.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 inverbConventions.{template}via the existingpathParamsInOrder(path)/pathParamRe(http/specverb/request.go:303region) - no operationId, noopresolution.query "a" "b"->Field{Location: query},body "x" "y"-> bodyFields,set state="closed"->FixedBody(the same KDL-nativeRawValue()pathguardfileuses athttp/guardfile/guardfile.go:688).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.Descriptorthe 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
specverbstays the CLI projection and one consumer ofopcore. 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, andBuild/Mount. Changes are mechanical:resolveDescriptornow returns anopcore.Descriptor(a type aliastype opDescriptor = opcore.Descriptorkeeps every existing reference compiling through the transition).buildLeaf(http/specverb/request.go:89) is unchanged in shape: samefieldFlagsToCLIflags, same universal--dry-run/--query/--output, samert.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 anopcore.Args, callOperation.Execute, then render withrespfmthonoring--query/--output, and route--dry-runthroughOperation.Preview. Same inputs, same URL, same fired request - the ward-kdl guardfile CLIs are byte-identical.buildCallRequest,fireCapture) unchanged - they are nowopcore.Runtimemethods reached fromspecverb.Belt-and-suspenders on the gate:
verb.Wrapalready runs the metachar gate for CLI leaves, andExecutere-runs it - both callpkg/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:
opcore) - descriptor, input-schema exposure, the restrict + metachar gates, request assembly + signing + fire, and the inline-op parser. Plus the OpenAPI source staying inspecverb.action*.go) is a CLI-side consumer of the core, callingExecuteper 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
http/opcorewith zero behavior change. MoveopDescriptor->Descriptor,fieldFlag->Field,runtime->Runtime, plusrequest.go(argBinder, fillPath, assemble*, authorize, resolveChain, fireCapture, baseForRequest) andrestrict.go. AddOperation.Execute(gates + assemble + fire, no render) and repointspecverb.actionForand the actions engine at it. Keeptype opDescriptor = opcore.Descriptoraliases so the diff is mechanical and every existingspecverbandexecverbtest stays green. Load-bearing, unblocks the rest.Descriptor.InputSchema()+Schema/Property(+ optionalJSONSchema()emitter) inopcore, with tests. Independent after PR1.specverb describecan optionally consume it.ParseInline) for the ward-mcp#6 grammar, plus factoringMethodForVerbout ofverbConventions. 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.opcore(+ inline source): builds its MCP tool list fromInputSchema, dispatchesExecuteon 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
http/opcore. A facade exported fromspecverbleaves the transitive urfave/cli dep, which the issue's neutral-entrypoint constraint forbids. Cost is a larger PR1 diff, bought back by aliases.opcore(or a siblinghttp/inlineop), notspecverb, for the same coupling reason. Naming and same-package-vs-sibling is Kai's call.specverbas a consumer, honoring #190. Only revisit if ward-mcp is meant to drive orchestration, which is out of scope here.Schemastruct now plus a thin draft-07JSONSchema()bytes emitter, keeping cli-guard MCP-agnostic. The MCP tool wrapper stays in ward-mcp.Argsshape. Recommend reusingargBinder's existing name-routing (it already routes a flat name onto path/query/body athttp/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 agentDecisions (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; reuseargBinder; neutralSchema+ JSONSchema emitter). Split into three sequential cli-guard PRs, all landing here:http/opcore(Descriptor + Runtime + self-guardingOperation.Execute), zero behavior change, aliases keep tests green. Dispatched headless. Load-bearing.Descriptor.InputSchema()+ neutral Schema/Property + JSONSchema emitter. Blocked on #197.ParseInlineinline-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.
🔒 Reserved by
ward agent --harness codex— containerengineer-codex-cli-guard-196on hostkais-macbook-pro-2.localis carrying this issue (reserved 2026-07-08T17:09:23Z). Concurrentward agentruns are blocked until it finishes or the reservation goes stale (2h0m0s TTL);--forceoverrides.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)
coilyco-flight-deck/cli-guard#196· branchissue-196· harnesscodex· workflowdirect-mainengineer-codex-cli-guard-196· wardv0.451.0· dispatched2026-07-08T17:09:23ZIssue body as seeded:
… (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 agentWARD-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
$refplaceholders, 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.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.