ward-kdl verb runtime foundation: 8 capability shapes + 3 safety primitives (BLOCKER) #73

Closed
opened 2026-06-09 05:57:48 +00:00 by coilysiren · 2 comments
Owner

Problem

coily is merging into ward wholesale. coily's forgejo verb surface (40 leaves across cmd/coily/ops_forgejo_*.go) is hand-written Go, each leaf a verb.Spec + (*Runner).WrapVerb (= cli-guard verb.Wrap + audit.Writer) wired by hand. The target representation in ward is ward-kdl - verbs declared in KDL, executed by a generic runtime - not 40 more hand-rolled Go constructors.

Before any verb can be expressed in ward-kdl (hand-migrated or generated), the ward-kdl runtime has to support the capability shapes those verbs actually use. This issue is that runtime. It blocks the forgejo migration and the OpenAPI->ward-kdl generator.

The 8 capability shapes

The 40 forgejo leaves reduce to ~8 shapes. The runtime must express each declaratively:

  1. GET-list + list printer - issue/label/milestone/release/repo list, actions task list (6 leaves)
  2. GET-single + detail printer - issue/milestone/release/repo view (4)
  3. POST-create (body from flags or --body-file) (4)
  4. PATCH-edit (sparse patch - only set fields the caller passed) (5)
  5. DELETE (5)
  6. PATCH-state toggle (the *StateCommand close/reopen pattern) (4-6)
  7. multipart POST - release upload-asset (1, unique)
  8. multi-step resolve - issue-label name->id, actions-task-logs job-walk (2, the only genuinely hard ones)

The 3 safety primitives (the load-bearing part)

These live today inside coily's hand-written forgejoAPIDo and per-verb code. In ward-kdl they must become runtime primitives, so that every verb - hand-written or generated - inherits them for free. This is the unlock: it dissolves the "generator can't model safety" design tension that blocks generation (see the generator issue).

  • 301-refusal on mutating verbs - refuse method-downgrading redirects on writes, so a renamed/transferred (alias) repo can't silently no-op a mutation (coily #178 / #160 / #205).
  • name->id resolution - resolve human names to ids against repo + org sets (labels today; the pattern generalizes - coily #159).
  • curated output - human-readable summaries (#N [state] title), not raw JSON passthrough, with the --query/--output projection rail folded in.

Shared transport to port (write once)

  • the forgejoAPIDo -> forgejoDoWithAliasRetry -> forgejoAPIRoundTrip chain (+ redirect check, repo-from-path)
  • SSM token fetch (forgejoAPIToken)
  • owner/name slug parse + validate
  • multipart variant (forgejoAPIPostMultipart) for shape 7

cli-guard spine (unchanged, reused)

Every verb still wraps through verb.Wrap(spec, writer) + audit.Writer + the decision.Evaluate profile gate. ward already consumes cli-guard for this; the ward-kdl runtime just needs to build a verb.Spec from a KDL declaration instead of from hand-written Go.

Note: 8 of the create/edit leaves carry stampPolicySkipped (the metacharacter-gate opt-out for free-text titles/bodies that go into a JSON body, never a shell). The KDL schema needs a per-verb flag for this; default off.

Acceptance

  • A KDL schema that can declare a verb of each of the 8 shapes.
  • The 3 safety primitives implemented as runtime behavior, opt-in per verb via KDL.
  • One end-to-end vertical slice proving the pipeline: pick the simplest leaf (label list or issue list), declare it in KDL, run it through the runtime, confirm parity with the current coily verb. Shake out the schema here before fanning out the rest.

Blocks

  • forgejo verb migration into ward-kdl (supersedes coily #186)
  • OpenAPI->ward-kdl generator + engine decision
## Problem coily is merging into ward wholesale. coily's forgejo verb surface (40 leaves across `cmd/coily/ops_forgejo_*.go`) is hand-written Go, each leaf a `verb.Spec` + `(*Runner).WrapVerb` (= cli-guard `verb.Wrap` + `audit.Writer`) wired by hand. The target representation in ward is **ward-kdl** - verbs declared in KDL, executed by a generic runtime - not 40 more hand-rolled Go constructors. Before any verb can be expressed in ward-kdl (hand-migrated **or** generated), the ward-kdl runtime has to support the capability shapes those verbs actually use. This issue is that runtime. **It blocks the forgejo migration and the OpenAPI->ward-kdl generator.** ## The 8 capability shapes The 40 forgejo leaves reduce to ~8 shapes. The runtime must express each declaratively: 1. **GET-list + list printer** - issue/label/milestone/release/repo list, actions task list (6 leaves) 2. **GET-single + detail printer** - issue/milestone/release/repo view (4) 3. **POST-create** (body from flags or `--body-file`) (4) 4. **PATCH-edit** (sparse patch - only set fields the caller passed) (5) 5. **DELETE** (5) 6. **PATCH-state toggle** (the `*StateCommand` close/reopen pattern) (4-6) 7. **multipart POST** - release upload-asset (1, unique) 8. **multi-step resolve** - issue-label name->id, actions-task-logs job-walk (2, the only genuinely hard ones) ## The 3 safety primitives (the load-bearing part) These live today inside coily's hand-written `forgejoAPIDo` and per-verb code. In ward-kdl they must become **runtime primitives**, so that every verb - hand-written or generated - inherits them for free. This is the unlock: it dissolves the "generator can't model safety" design tension that blocks generation (see the generator issue). - **301-refusal on mutating verbs** - refuse method-downgrading redirects on writes, so a renamed/transferred (alias) repo can't silently no-op a mutation (coily #178 / #160 / #205). - **name->id resolution** - resolve human names to ids against repo + org sets (labels today; the pattern generalizes - coily #159). - **curated output** - human-readable summaries (`#N [state] title`), not raw JSON passthrough, with the `--query`/`--output` projection rail folded in. ## Shared transport to port (write once) - the `forgejoAPIDo` -> `forgejoDoWithAliasRetry` -> `forgejoAPIRoundTrip` chain (+ redirect check, repo-from-path) - SSM token fetch (`forgejoAPIToken`) - `owner/name` slug parse + validate - multipart variant (`forgejoAPIPostMultipart`) for shape 7 ## cli-guard spine (unchanged, reused) Every verb still wraps through `verb.Wrap(spec, writer)` + `audit.Writer` + the `decision.Evaluate` profile gate. ward already consumes cli-guard for this; the ward-kdl runtime just needs to build a `verb.Spec` from a KDL declaration instead of from hand-written Go. Note: 8 of the create/edit leaves carry `stampPolicySkipped` (the metacharacter-gate opt-out for free-text titles/bodies that go into a JSON body, never a shell). The KDL schema needs a per-verb flag for this; default off. ## Acceptance - A KDL schema that can declare a verb of each of the 8 shapes. - The 3 safety primitives implemented as runtime behavior, opt-in per verb via KDL. - One end-to-end vertical slice proving the pipeline: pick the simplest leaf (`label list` or `issue list`), declare it in KDL, run it through the runtime, confirm parity with the current coily verb. Shake out the schema here **before** fanning out the rest. ## Blocks - forgejo verb migration into ward-kdl (supersedes coily #186) - OpenAPI->ward-kdl generator + engine decision
Author
Owner

Architecture clarification + status (from the #74 tracer session)

Kai settled the engine in session (see #74): KDL slices and annotates Go generated via kin. Two framings in this issue's body shift as a result, while the substance stays:

  • "verbs declared in KDL, executed by a generic runtime" -> there is no separate interpretive runtime. KDL is the terse slice/annotate policy, and specgen generates the consumer Go (verb.Wrap + SSM + Mount) that runs it. The "runtime" is the generated binary plus the cli-guard spine, not a KDL interpreter.
  • The 8 shapes and 3 safety primitives are still the real, load-bearing work. They become metadata the Guardfile opts each verb into, emitted by the generator, inherited by hand-written and generated verbs alike. That part of this issue is unchanged.

What is done

  • Parse layer now kin-openapi (cli-guard f31117f), so spec-loading and $ref resolution are no longer hand-rolled.
  • Shapes present (M0): GET-list, GET-single, POST-create, DELETE, proven on the org slice.
  • Safety primitives present: 301-refusal on mutating verbs (the redirect-refusing client). Deny-by-default expansion table. Destructive-leaf marking (the --yes UX is still pending).

What remains (this issue stays open to track it)

  • Shapes: PATCH-edit (sparse), PATCH-state toggle (close/reopen), multipart POST (release upload-asset), multi-step resolve (label name->id, actions-task-logs job-walk).
  • Safety primitives: name->id resolution, curated output, plus the --yes destructive-confirm UX.
  • KDL schema for the per-verb stampPolicySkipped opt-out (free-text title/body), default off.

Follow-ups filed alongside this comment cover the deeper IR migration and the ward wiring.

## Architecture clarification + status (from the #74 tracer session) Kai settled the engine in session (see #74): **KDL slices and annotates Go generated via kin**. Two framings in this issue's body shift as a result, while the substance stays: - "verbs declared in KDL, executed by a generic runtime" -> there is no separate interpretive runtime. KDL is the terse slice/annotate policy, and `specgen` generates the consumer Go (`verb.Wrap` + SSM + `Mount`) that runs it. The "runtime" is the generated binary plus the cli-guard spine, not a KDL interpreter. - The 8 shapes and 3 safety primitives are still the real, load-bearing work. They become metadata the Guardfile opts each verb into, emitted by the generator, inherited by hand-written and generated verbs alike. That part of this issue is unchanged. ## What is done - **Parse layer** now kin-openapi (cli-guard `f31117f`), so spec-loading and `$ref` resolution are no longer hand-rolled. - **Shapes present (M0):** GET-list, GET-single, POST-create, DELETE, proven on the org slice. - **Safety primitives present:** 301-refusal on mutating verbs (the redirect-refusing client). Deny-by-default expansion table. Destructive-leaf marking (the `--yes` UX is still pending). ## What remains (this issue stays open to track it) - **Shapes:** PATCH-edit (sparse), PATCH-state toggle (close/reopen), multipart POST (release upload-asset), multi-step resolve (label name->id, actions-task-logs job-walk). - **Safety primitives:** name->id resolution, curated output, plus the `--yes` destructive-confirm UX. - **KDL schema** for the per-verb `stampPolicySkipped` opt-out (free-text title/body), default off. Follow-ups filed alongside this comment cover the deeper IR migration and the ward wiring.
Author
Owner

What

Grant the final HTTP groups on cli-guard v0.10.0 (coilyco-flight-deck/cli-guard#129): issue-labels (list/add/set/remove - the API accepts names directly, so the anticipated name->id pre-flight primitive dissolved) and release upload-asset (multipart formData). 40 verbs mount.

Runtime foundation complete

This closes the runtime-foundation blocker: every capability shape the 40-leaf forgejo surface needs is now a specverb primitive or a plain expansion row:

  1. GET-list + query-param filtering - done (v0.8.0)
  2. GET-single - done (v0.7.0)
  3. POST-create incl. arrays + --body-file - done (v0.8.0)
  4. PATCH-edit sparse - free in the engine from the start
  5. DELETE - done (v0.7.0)
  6. PATCH state toggles - fixed-body rows (v0.8.0/v0.9.0)
  7. multipart POST - done (v0.10.0)
  8. multi-step resolve - dissolved upstream (names accepted directly); actions task-logs job-walk deferred with the admin/doctor kubectl-exec leaves to the exec-dialect track

The 3 safety primitives: 301-refusal on writes (engine default client), name->id (dissolved), curated output (--query/--output rail; the #N [state] title summaries ride respfmt as a follow-up).

Remaining non-HTTP: admin/doctor (kubectl-exec transport) and actions task logs - tracked under the exec dialect design.

## What Grant the final HTTP groups on cli-guard v0.10.0 (https://forgejo.coilysiren.me/coilyco-flight-deck/cli-guard/issues/129): issue-labels (list/add/set/remove - the API accepts names directly, so the anticipated name->id pre-flight primitive dissolved) and release upload-asset (multipart formData). 40 verbs mount. ## Runtime foundation complete This closes the runtime-foundation blocker: every capability shape the 40-leaf forgejo surface needs is now a specverb primitive or a plain expansion row: 1. GET-list + query-param filtering - done (v0.8.0) 2. GET-single - done (v0.7.0) 3. POST-create incl. arrays + --body-file - done (v0.8.0) 4. PATCH-edit sparse - free in the engine from the start 5. DELETE - done (v0.7.0) 6. PATCH state toggles - fixed-body rows (v0.8.0/v0.9.0) 7. multipart POST - done (v0.10.0) 8. multi-step resolve - dissolved upstream (names accepted directly); actions task-logs job-walk deferred with the admin/doctor kubectl-exec leaves to the exec-dialect track The 3 safety primitives: 301-refusal on writes (engine default client), name->id (dissolved), curated output (--query/--output rail; the #N [state] title summaries ride respfmt as a follow-up). Remaining non-HTTP: admin/doctor (kubectl-exec transport) and actions task logs - tracked under the exec dialect design.
Sign in to join this conversation.
No project
No assignees
1 participant
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/ward#73
No description provided.