OpenAPI->ward-kdl generator: split parse(library)/emit(custom), Speakeasy rejected #74

Closed
opened 2026-06-09 05:58:40 +00:00 by coilysiren · 1 comment
Owner

Depends on #73 (ward-kdl runtime). Part of coily->ward wholesale merge.

Problem

coily generates its REST wrapper surfaces (discord, glama, trello, sentry - the *_generated.go files) with a custom generator, scripts/openapi-to-coily.py (761 lines): OpenAPI 3.x spec -> Go cli.Command tree, one leaf per (path, method), bearer/key auth from SSM. Forgejo is the one major surface still hand-written.

Two things are now true at once:

  1. The output target is changing from Go cli.Command to ward-kdl (per #73).
  2. We want the long tail of forgejo verbs (org repo listing, teams, hooks, etc.) by generation, not by hand (was coily #186).

So the generator needs a rebuild. This issue decides the engine.

Speakeasy (and peers) evaluated - rejected

Speakeasy was evaluated (proving-ground: speakeasy-ward, speakeasy-cli-guard). Rejected for this slot:

  • Paid, enterprise-only. Not viable.
  • Wrong output category. Speakeasy / Fern / openapi-generator / oapi-codegen / ogen / Kiota all emit typed SDK clients (structs per schema, a method per operation). They do not emit a cli-guard/ward verb surface - audit verb names, SSM auth, verb.Wrap shaping, ward-kdl. The CLI-emitting tiers that exist (Speakeasy CLI gen) are exactly the enterprise lock. A library cannot produce the thing our generator produces, because that thing is our guard surface.

Conclusion: the valuable, unusual part of our codegen is the emit layer (IR -> ward-kdl + cli-guard wiring). No library replaces it - by construction.

Decision: split the generator into two layers

  • Parse layer - OpenAPI spec -> IR. Offload to a library. This kills the hand-rolled $ref resolution and spec-loading in openapi-to-coily.py that rots whenever Forgejo bumps its spec. Candidates: kin-openapi (if the generator moves to Go alongside ward) or openapi-core (if it stays Python). Pick one.
  • Emit layer - IR -> ward-kdl verb declarations. Stays custom forever. This is where the 8 shapes (#73) get emitted as KDL.

The safety design-tension from old coily #186 (generator can't model the 301-refusal / name->id / curated output) dissolves once #73 lands: those are runtime primitives in ward-kdl, inherited by generated and hand-written verbs alike. The generator no longer has to model safety - it just emits the KDL that opts each verb into the right primitives.

Scope notes

  • Forgejo spec at https://forgejo.coilysiren.me/swagger.v1.json. It's large - scope generation to the tags ward actually needs (repo, issue, label, org, pulls, actions). Pin the spec version; Forgejo's spec shifts across releases.
  • Generated mutating verbs MUST opt into the 301-refusal primitive (#73). Non-negotiable - it's the alias silent-no-op fix.

Acceptance

  • Engine decision recorded (this issue): parse-layer library chosen, emit-layer stays custom, Speakeasy rejection rationale captured above so it isn't re-litigated.
  • Generator emits ward-kdl (not Go), consuming the #73 runtime.
  • One generated forgejo tag (e.g. label or issue) round-trips: spec -> ward-kdl -> runtime -> parity with hand-written verb.
Depends on #73 (ward-kdl runtime). Part of coily->ward wholesale merge. ## Problem coily generates its REST wrapper surfaces (`discord`, `glama`, `trello`, `sentry` - the `*_generated.go` files) with a **custom** generator, `scripts/openapi-to-coily.py` (761 lines): OpenAPI 3.x spec -> Go `cli.Command` tree, one leaf per (path, method), bearer/key auth from SSM. Forgejo is the one major surface still hand-written. Two things are now true at once: 1. The output target is changing from Go `cli.Command` to **ward-kdl** (per #73). 2. We want the long tail of forgejo verbs (org repo listing, teams, hooks, etc.) by generation, not by hand (was coily #186). So the generator needs a rebuild. This issue decides the **engine**. ## Speakeasy (and peers) evaluated - rejected Speakeasy was evaluated (proving-ground: `speakeasy-ward`, `speakeasy-cli-guard`). Rejected for this slot: - **Paid, enterprise-only.** Not viable. - **Wrong output category.** Speakeasy / Fern / openapi-generator / oapi-codegen / ogen / Kiota all emit **typed SDK clients** (structs per schema, a method per operation). They do **not** emit a cli-guard/ward verb surface - audit verb names, SSM auth, `verb.Wrap` shaping, ward-kdl. The CLI-emitting tiers that exist (Speakeasy CLI gen) are exactly the enterprise lock. A library cannot produce the thing our generator produces, because that thing is *our guard surface*. Conclusion: the valuable, unusual part of our codegen is the **emit layer** (IR -> ward-kdl + cli-guard wiring). No library replaces it - by construction. ## Decision: split the generator into two layers - **Parse layer** - OpenAPI spec -> IR. **Offload to a library.** This kills the hand-rolled `$ref` resolution and spec-loading in `openapi-to-coily.py` that rots whenever Forgejo bumps its spec. Candidates: `kin-openapi` (if the generator moves to Go alongside ward) or `openapi-core` (if it stays Python). Pick one. - **Emit layer** - IR -> **ward-kdl** verb declarations. Stays custom forever. This is where the 8 shapes (#73) get emitted as KDL. The safety design-tension from old coily #186 (generator can't model the 301-refusal / name->id / curated output) **dissolves once #73 lands**: those are runtime primitives in ward-kdl, inherited by generated and hand-written verbs alike. The generator no longer has to model safety - it just emits the KDL that opts each verb into the right primitives. ## Scope notes - Forgejo spec at `https://forgejo.coilysiren.me/swagger.v1.json`. It's large - scope generation to the tags ward actually needs (repo, issue, label, org, pulls, actions). **Pin the spec version**; Forgejo's spec shifts across releases. - Generated mutating verbs MUST opt into the 301-refusal primitive (#73). Non-negotiable - it's the alias silent-no-op fix. ## Acceptance - Engine decision recorded (this issue): parse-layer library chosen, emit-layer stays custom, Speakeasy rejection rationale captured above so it isn't re-litigated. - Generator emits ward-kdl (not Go), consuming the #73 runtime. - One generated forgejo tag (e.g. `label` or `issue`) round-trips: spec -> ward-kdl -> runtime -> parity with hand-written verb.
Author
Owner

Engine decision recorded (and one wording correction)

Kai clarified the architecture in session: KDL slices and annotates Go that is generated via kin. That refines this issue's parse/emit split into a concrete, landed shape:

  • Parse layer = kin-openapi (the library). This kills the hand-rolled Swagger 2.0 reader and one-hop $ref resolution that rotted on every Forgejo spec bump. A 2.0 doc now converts to 3.x and all refs resolve up front, the engine reads an inlined IR carrying no $ref. OpenAPI 3.x specs load directly.
  • Emit layer = custom, stays custom. specgen renders the consumer's main.go (guarded verb.Wrap, SSM resolver, Mount). The valuable, unusual part is the IR -> guarded-verb-tree wiring, exactly as this issue argued.
  • KDL = slice + annotate. The Guardfile is a terse non-executable policy (can read orgs) that selects which operations mount and carries the cli-guard control metadata (auth, destructive marking, the safety primitives). It is not a verbose verb-declaration format.
  • Speakeasy rejected. Rationale in the issue body stands and is not re-litigated.

Wording correction. This issue's acceptance said "generator emits ward-kdl (not Go), consuming a generic #73 runtime." The implemented and chosen architecture is the inverse on that one axis: the generator emits Go via generate-and-compile (specgen), and KDL is the slice/annotate input, not the emit target. There is no separate interpretive runtime. Everything else in this issue holds.

Tracer bullet landed

cli-guard feat/specverb-kin-parse-layer (commit f31117f) swaps the parse layer to kin-openapi behind the existing IR boundary, emit path untouched. The forgejo org slice round-trips against the real pinned lock: org get/list/create/delete mount with identical method/path shapes, and org create promotes the CreateOrgOption body (resolved via kin's deref, not TrimPrefix) into the same scalar flags as before. Full cli-guard suite, vet, lint, godoc, and the catalog pre-commit suite pass. A TestOpenAPI3xAccepted proves the new 3.x branch, and the stale TestSwagger2Required was corrected to TestUnrecognizedSpecVersionRejected.

The acceptance bullet "engine decision recorded" is satisfied by this comment. The remaining bullets (the generator covering the long forgejo tail, and the non-org tags) fold into the shape work tracked under #73 plus the follow-ups filed alongside this comment.

## Engine decision recorded (and one wording correction) Kai clarified the architecture in session: **KDL slices and annotates Go that is generated via kin**. That refines this issue's parse/emit split into a concrete, landed shape: - **Parse layer = kin-openapi** (the library). This kills the hand-rolled Swagger 2.0 reader and one-hop `$ref` resolution that rotted on every Forgejo spec bump. A 2.0 doc now converts to 3.x and all refs resolve up front, the engine reads an inlined IR carrying no `$ref`. OpenAPI 3.x specs load directly. - **Emit layer = custom, stays custom.** `specgen` renders the consumer's `main.go` (guarded `verb.Wrap`, SSM resolver, `Mount`). The valuable, unusual part is the IR -> guarded-verb-tree wiring, exactly as this issue argued. - **KDL = slice + annotate.** The Guardfile is a terse non-executable policy (`can read orgs`) that selects which operations mount and carries the cli-guard control metadata (auth, destructive marking, the safety primitives). It is not a verbose verb-declaration format. - **Speakeasy rejected.** Rationale in the issue body stands and is not re-litigated. **Wording correction.** This issue's acceptance said "generator emits ward-kdl (not Go), consuming a generic #73 runtime." The implemented and chosen architecture is the inverse on that one axis: the generator **emits Go** via generate-and-compile (specgen), and KDL is the slice/annotate input, not the emit target. There is no separate interpretive runtime. Everything else in this issue holds. ## Tracer bullet landed cli-guard `feat/specverb-kin-parse-layer` (commit `f31117f`) swaps the parse layer to kin-openapi behind the existing IR boundary, emit path untouched. The forgejo **org** slice round-trips against the real pinned lock: `org get/list/create/delete` mount with identical method/path shapes, and `org create` promotes the `CreateOrgOption` body (resolved via kin's deref, not `TrimPrefix`) into the same scalar flags as before. Full cli-guard suite, vet, lint, godoc, and the catalog pre-commit suite pass. A `TestOpenAPI3xAccepted` proves the new 3.x branch, and the stale `TestSwagger2Required` was corrected to `TestUnrecognizedSpecVersionRejected`. The acceptance bullet "engine decision recorded" is satisfied by this comment. The remaining bullets (the generator covering the long forgejo tail, and the non-org tags) fold into the shape work tracked under #73 plus the follow-ups filed alongside this comment.
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#74
No description provided.