ward-kdl: port the curated forgejo surface into the read/write/admin tiers (Phase 1: faithful move, monolith stays) #278

Closed
opened 2026-06-24 09:09:05 +00:00 by coilyco-ops · 5 comments
Member

Background

80577da (closes #262) moved the forgejo tiers into self-contained per-tier subdirs - cmd/ward-kdl/ward-kdl-{read,write,admin}/, each with its own guardfile, pruned forgejo.swagger.lock.json, specverb.lock, generated main.go, and reference doc, chained read ⊂ write ⊂ admin via inherit (cli-guard#160). That layout is architecturally correct: the singleton-bearing read root declares spec/base-url/auth, write/admin inherit it across sibling subdirs by quoted relative path, and restrict owner matches coily* is re-declared per tier (it does not inherit).

The gap: the tier guardfiles express their grants as bare wildcards -

  • ward-kdl-readcan get * + can list * (133 verbs)
  • ward-kdl-write → inherit read + can create * can edit * (228 verbs)
  • ward-kdl-admin → inherit write + can delete * (267 verbs)

so they are not attached to the real, curated forgejo surface that lives in the monolith cmd/ward-kdl/ward-kdl.forgejo.guardfile.kdl (329 lines). The monolith is a hand-curated subset hardened across ward#107/#109/#92/#151/#170/#218/#225, and the wildcards both over-grant (every get/list/create/edit/delete-able resource in the swagger spec, not the curated subset) and drop every curated construct:

  • the never denials - fork/archive/unarchive repo, create/delete org, repo-level create/edit label (policy-disabled, ward#107), delete issue, view/list pr
  • the op pins for ambiguous resolution - createOrgRepo, orgListRepos, userListRepos, issueCreateLabel, issueEditLabel, issueGetComments
  • the four compound actions - list-all issue (auto-paginate), ci-watch (poll a run to terminal), move-issue (copy + back-link + close source), view issue (shadow that chains the comment thread, ward#170)
  • the fixed-body toggles - close/reopen milestone, close/reopen issue (body state=...)

Goal

Do the real logical move (copy): port the curated monolith surface into the three tiers, tier-sliced, preserving every denial, op-pin, toggle, and compound action. The wildcards are a placeholder; replace them with the explicit curated leaves distributed by scope. After this, the three binaries expose exactly the curated surface (so verb counts drop from 133/228/267 to the curated ~30-leaf surface - that shrink is intended, not a regression).

This issue is Phase 1 (faithful port, headless-able). The monolith stays in place and ward ops forgejo is untouched. Phase 2 (retire the monolith, decide how ward ops forgejo selects a tier) is a separate decision Kai owns - see "Out of scope / open semantics" below. Do not retire the monolith or rewire ward ops forgejo in this issue.

Per-tier distribution (the map)

Port each construct from ward-kdl.forgejo.guardfile.kdl to the tier below. Carry the describes/rationale comments verbatim.

READ tier (ward-kdl-read, the singleton root - keep spec/base-url/auth/restrict):

  • get repo, search repo (cross-repo finder)
  • get org, list org, list org-repo (op orgListRepos), list user-repo (op userListRepos)
  • get org-label, list org-label
  • get milestone, list milestone
  • get issue, view issue, list issue, search issue, list issue-comment (op issueGetComments)
  • get release, list release
  • list issue-label
  • list tasks
  • actions: list-all issue, ci-watch, view issue (shadow chaining list issue-comment)

WRITE tier (ward-kdl-write, inherits read + re-declares restrict):

  • create repo, edit repo
  • create org-repo (op createOrgRepo)
  • create org-label, edit org-label
  • create milestone, edit milestone, close milestone (body state=closed), reopen milestone (body state=open)
  • create issue, edit issue, comment issue, close issue (body state=closed), reopen issue (body state=open)
  • create release, edit release, upload-asset release
  • add issue-label, set issue-label, remove issue-label
  • action: move-issue (calls view issue [inherited from read] + create issue + comment issue + close issue)

ADMIN tier (ward-kdl-admin, inherits write + re-declares restrict):

  • delete repo, delete org-label, delete milestone, delete release

The never denials - placement depends on inherit semantics (see open question #2). Natural homes by the verb family they shadow:

  • read: never view pr, never list pr
  • write: never create org, never create label (op issueCreateLabel), never edit label (op issueEditLabel), never fork repo, never archive repo, never unarchive repo
  • admin: never delete org, never delete issue

Open questions to resolve during impl

  1. Wildcard vs explicit. Recommended: replace the can get *-style wildcards with the explicit curated leaves above. Wildcards cannot express the per-resource exceptions (e.g. delete repo allowed but delete issue denied; create repo allowed but create org denied) or the op-pins/actions, and they auto-grant any new swagger endpoint - which is exactly the curation ward#107/#109 exist to prevent. If Kai prefers wildcard-base + never-overrides for terseness, that's a deliberate trade and should be confirmed first.
  2. Does never inherit across the inherit chain? restrict is known child-local (re-declared per tier). If never does propagate up, declare all denials once in the read root (single source of truth) and drop the per-tier placement. If it does not, place each denial in the tier whose verb family it shadows (table above), and repeat where a lower-tier deny must also hold in a higher tier. Verify this against cli-guard inherit semantics (cli-guard#160) before choosing.
  3. Do inherited actions compose into a child tier? move-issue (write) calls view issue, which is defined in the read tier. Confirm an inherited action/leaf is callable from a child-tier action; if not, the read-side calls must be reachable some other way.

Implementation steps

  1. Rewrite the three tier guardfiles (cmd/ward-kdl/ward-kdl-{read,write,admin}/ward-kdl.forgejo.{read,write,admin}.guardfile.kdl) per the map, resolving the three open questions first.
  2. Re-lock/build/gen all three: make build-ward-kdl-forgejo-tiers (runs lock + build + gen per subdir, then relocates each reviewed doc to docs/). The per-tier forgejo.swagger.lock.json + specverb.lock will shrink to the curated slice; commit the regenerated locks, main.go, and docs/ward-kdl.forgejo.{read,write,admin}.guardfile.md.
  3. Leave the monolith ward-kdl.forgejo.guardfile.kdl, sync-ops-assets, cmd/ward/opsassets/*, and opsassets_test.go untouched (Phase 2).

Validation / acceptance

  • make build-ward-kdl-forgejo-tiers succeeds; all three binaries build.
  • Diff the effective verb surface of ward-kdl-admin against the monolith - they should match leaf-for-leaf (admin = full curated CRUD). ward-kdl-write = admin minus delete; ward-kdl-read = get/list/search/view only.
  • Every monolith never, op pin, fixed-body toggle, and the four actions are present in the appropriate tier (grep the generated docs / --help).
  • restrict owner matches coily* present in all three; spec/base-url/auth only in read.
  • Existing tier tests pass; no change to ward ops forgejo behavior (monolith untouched).

Out of scope / open semantics (Phase 2, Kai's call - do NOT do here)

ward ops forgejo (ward#92) embeds a copy of the monolith (sync-ops-assetscmd/ward/opsassets/forgejo.guardfile.kdl), so the monolith is today's source of truth for the entire ward ops forgejo surface that AGENTS.md routes everything through. "Entirely move to the scoped variants" therefore needs a decision on what ward ops forgejo becomes: embed the admin tier (full CRUD ≈ the monolith)? gain a --tier/default? split into ward ops forgejo-{read,write,admin}? This is the unresolved "three ward-kdl binaries" semantics. Keep it out of Phase 1.

References

ward#262 (the move this builds on), ward#240 (the read ⊂ write ⊂ admin chain), ward#107 (repo-label policy disable), ward#109 (org/repo hardening), ward#92 (ward ops forgejo embed + route survey), ward#151 (coilyco-ops bot auth), ward#170 (view issue thread), ward#218 (create org-repo), ward#225 (view issue projection), cli-guard#147 (auto-resolution), cli-guard#159 (wildcard grants), cli-guard#160 (inherit + child-local restrict).

## Background `80577da` (closes #262) moved the forgejo tiers into self-contained per-tier subdirs - `cmd/ward-kdl/ward-kdl-{read,write,admin}/`, each with its own guardfile, pruned `forgejo.swagger.lock.json`, `specverb.lock`, generated `main.go`, and reference doc, chained `read ⊂ write ⊂ admin` via `inherit` (cli-guard#160). That layout is **architecturally correct**: the singleton-bearing read root declares `spec`/`base-url`/`auth`, write/admin `inherit` it across sibling subdirs by quoted relative path, and `restrict owner matches coily*` is re-declared per tier (it does not inherit). **The gap:** the tier guardfiles express their grants as bare wildcards - - `ward-kdl-read` → `can get *` + `can list *` (133 verbs) - `ward-kdl-write` → inherit read + `can create *` `can edit *` (228 verbs) - `ward-kdl-admin` → inherit write + `can delete *` (267 verbs) so they are **not attached to the real, curated forgejo surface** that lives in the monolith `cmd/ward-kdl/ward-kdl.forgejo.guardfile.kdl` (329 lines). The monolith is a hand-curated subset hardened across ward#107/#109/#92/#151/#170/#218/#225, and the wildcards both **over-grant** (every get/list/create/edit/delete-able resource in the swagger spec, not the curated subset) and **drop every curated construct**: - the `never` denials - `fork`/`archive`/`unarchive repo`, `create`/`delete org`, repo-level `create`/`edit label` (policy-disabled, ward#107), `delete issue`, `view`/`list pr` - the `op` pins for ambiguous resolution - `createOrgRepo`, `orgListRepos`, `userListRepos`, `issueCreateLabel`, `issueEditLabel`, `issueGetComments` - the four compound `action`s - `list-all issue` (auto-paginate), `ci-watch` (poll a run to terminal), `move-issue` (copy + back-link + close source), `view issue` (shadow that chains the comment thread, ward#170) - the fixed-body toggles - `close`/`reopen milestone`, `close`/`reopen issue` (`body state=...`) ## Goal Do the **real logical move (copy)**: port the curated monolith surface into the three tiers, tier-sliced, preserving every denial, op-pin, toggle, and compound action. The wildcards are a placeholder; replace them with the explicit curated leaves distributed by scope. After this, the three binaries expose exactly the curated surface (so verb counts drop from 133/228/267 to the curated ~30-leaf surface - **that shrink is intended, not a regression**). This issue is **Phase 1** (faithful port, headless-able). The monolith stays in place and `ward ops forgejo` is untouched. **Phase 2** (retire the monolith, decide how `ward ops forgejo` selects a tier) is a separate decision Kai owns - see "Out of scope / open semantics" below. Do not retire the monolith or rewire `ward ops forgejo` in this issue. ## Per-tier distribution (the map) Port each construct from `ward-kdl.forgejo.guardfile.kdl` to the tier below. Carry the describes/rationale comments verbatim. **READ tier** (`ward-kdl-read`, the singleton root - keep `spec`/`base-url`/`auth`/`restrict`): - `get repo`, `search repo` (cross-repo finder) - `get org`, `list org`, `list org-repo` (op `orgListRepos`), `list user-repo` (op `userListRepos`) - `get org-label`, `list org-label` - `get milestone`, `list milestone` - `get issue`, `view issue`, `list issue`, `search issue`, `list issue-comment` (op `issueGetComments`) - `get release`, `list release` - `list issue-label` - `list tasks` - actions: `list-all issue`, `ci-watch`, `view issue` (shadow chaining `list issue-comment`) **WRITE tier** (`ward-kdl-write`, inherits read + re-declares `restrict`): - `create repo`, `edit repo` - `create org-repo` (op `createOrgRepo`) - `create org-label`, `edit org-label` - `create milestone`, `edit milestone`, `close milestone` (`body state=closed`), `reopen milestone` (`body state=open`) - `create issue`, `edit issue`, `comment issue`, `close issue` (`body state=closed`), `reopen issue` (`body state=open`) - `create release`, `edit release`, `upload-asset release` - `add issue-label`, `set issue-label`, `remove issue-label` - action: `move-issue` (calls `view issue` [inherited from read] + `create issue` + `comment issue` + `close issue`) **ADMIN tier** (`ward-kdl-admin`, inherits write + re-declares `restrict`): - `delete repo`, `delete org-label`, `delete milestone`, `delete release` **The `never` denials** - placement depends on inherit semantics (see open question #2). Natural homes by the verb family they shadow: - read: `never view pr`, `never list pr` - write: `never create org`, `never create label` (op `issueCreateLabel`), `never edit label` (op `issueEditLabel`), `never fork repo`, `never archive repo`, `never unarchive repo` - admin: `never delete org`, `never delete issue` ## Open questions to resolve during impl 1. **Wildcard vs explicit.** Recommended: replace the `can get *`-style wildcards with the explicit curated leaves above. Wildcards cannot express the per-resource exceptions (e.g. `delete repo` allowed but `delete issue` denied; `create repo` allowed but `create org` denied) or the op-pins/actions, and they auto-grant any new swagger endpoint - which is exactly the curation ward#107/#109 exist to prevent. If Kai prefers wildcard-base + `never`-overrides for terseness, that's a deliberate trade and should be confirmed first. 2. **Does `never` inherit across the `inherit` chain?** `restrict` is known child-local (re-declared per tier). If `never` *does* propagate up, declare all denials once in the read root (single source of truth) and drop the per-tier placement. If it does **not**, place each denial in the tier whose verb family it shadows (table above), and repeat where a lower-tier deny must also hold in a higher tier. **Verify this against cli-guard inherit semantics (cli-guard#160) before choosing.** 3. **Do inherited `action`s compose into a child tier?** `move-issue` (write) calls `view issue`, which is defined in the read tier. Confirm an inherited action/leaf is callable from a child-tier action; if not, the read-side calls must be reachable some other way. ## Implementation steps 1. Rewrite the three tier guardfiles (`cmd/ward-kdl/ward-kdl-{read,write,admin}/ward-kdl.forgejo.{read,write,admin}.guardfile.kdl`) per the map, resolving the three open questions first. 2. Re-lock/build/gen all three: `make build-ward-kdl-forgejo-tiers` (runs `lock` + `build` + `gen` per subdir, then relocates each reviewed doc to `docs/`). The per-tier `forgejo.swagger.lock.json` + `specverb.lock` will shrink to the curated slice; commit the regenerated locks, `main.go`, and `docs/ward-kdl.forgejo.{read,write,admin}.guardfile.md`. 3. Leave the monolith `ward-kdl.forgejo.guardfile.kdl`, `sync-ops-assets`, `cmd/ward/opsassets/*`, and `opsassets_test.go` untouched (Phase 2). ## Validation / acceptance - `make build-ward-kdl-forgejo-tiers` succeeds; all three binaries build. - Diff the effective verb surface of `ward-kdl-admin` against the monolith - they should match leaf-for-leaf (admin = full curated CRUD). `ward-kdl-write` = admin minus `delete`; `ward-kdl-read` = get/list/search/view only. - Every monolith `never`, `op` pin, fixed-body toggle, and the four actions are present in the appropriate tier (grep the generated docs / `--help`). - `restrict owner matches coily*` present in all three; `spec`/`base-url`/`auth` only in read. - Existing tier tests pass; no change to `ward ops forgejo` behavior (monolith untouched). ## Out of scope / open semantics (Phase 2, Kai's call - do NOT do here) `ward ops forgejo` (ward#92) embeds a **copy of the monolith** (`sync-ops-assets` → `cmd/ward/opsassets/forgejo.guardfile.kdl`), so the monolith is today's source of truth for the entire `ward ops forgejo` surface that AGENTS.md routes everything through. "Entirely move to the scoped variants" therefore needs a decision on **what `ward ops forgejo` becomes**: embed the admin tier (full CRUD ≈ the monolith)? gain a `--tier`/default? split into `ward ops forgejo-{read,write,admin}`? This is the unresolved "three ward-kdl binaries" semantics. Keep it out of Phase 1. ## References ward#262 (the move this builds on), ward#240 (the read ⊂ write ⊂ admin chain), ward#107 (repo-label policy disable), ward#109 (org/repo hardening), ward#92 (`ward ops forgejo` embed + route survey), ward#151 (coilyco-ops bot auth), ward#170 (`view issue` thread), ward#218 (`create org-repo`), ward#225 (`view issue` projection), cli-guard#147 (auto-resolution), cli-guard#159 (wildcard grants), cli-guard#160 (`inherit` + child-local `restrict`).
Author
Member

Re-triage (goose-triage method, claude-macos-kais-macbook-pro-2 as the judgment engine, 2026-06-24)

  • P2 - Faithful port of the curated forgejo surface into the read/write/admin tiers, well-specified Phase 1 of a committed refactor.
  • interactive - Large generated-surface move where over/under-grant is easy, so a human reviews the tier diff.
<!-- goose-triage --> **Re-triage** (goose-triage method, claude-macos-kais-macbook-pro-2 as the judgment engine, 2026-06-24) - **P2** - Faithful port of the curated forgejo surface into the read/write/admin tiers, well-specified Phase 1 of a committed refactor. - **interactive** - Large generated-surface move where over/under-grant is easy, so a human reviews the tier diff.
Owner

Threaded into the full tier-migration epic ward#339 as the forgejo half of the foundation schema (signoz half: ward#338). Open Q2/Q3 here are resolved by cli-guard#169 (never-propagates-upward + an override keyword for conscious upward escalation).

Threaded into the full tier-migration epic ward#339 as the forgejo half of the foundation schema (signoz half: ward#338). Open Q2/Q3 here are resolved by cli-guard#169 (never-propagates-upward + an override keyword for conscious upward escalation).
Author
Member

Flipped headless: the spec is concrete (replace bare wildcard grants with the curated per-tier verb lists). Post-#455 note: the tier binaries are internal-only surfaces now - the broker path (#329) is their consumer, not the public release page. Recorded by Claude Code (Fable) during the 2026-07-01 ward launch triage session with Kai.

Flipped headless: the spec is concrete (replace bare wildcard grants with the curated per-tier verb lists). Post-#455 note: the tier binaries are internal-only surfaces now - the broker path (#329) is their consumer, not the public release page. Recorded by Claude Code (Fable) during the 2026-07-01 ward launch triage session with Kai.
Author
Member

🔒 Reserved by ward agent --driver claude — container engineer-claude-ward-278 on host KAI-DESKTOP-TOWER is carrying this issue (reserved 2026-07-04T17:46:36Z). 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).

— Claude (she/her), via ward agent

<!-- ward-agent-reservation --> 🔒 Reserved by `ward agent --driver claude` — container `engineer-claude-ward-278` on host `KAI-DESKTOP-TOWER` is carrying this issue (reserved 2026-07-04T17:46:36Z). 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). <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Owner

WARD-OUTCOME: done - forgejo read/write/admin tier guardfiles now carry the curated monolith surface (explicit leaves, all denials, op-pins, toggles, four actions); admin diffs empty against the monolith, landed on main.

Honest retro: well-specified issue, and the clone genuinely had everything I needed, so most of this was careful transcription rather than discovery. The one real fork was open Q3 (do actions inherit?). cli-guard v0.72.0 says action blocks are child-local, and the issue's draft map placed each action in a single tier - but following that literally would make a write-tier view issue weaker than read's, dropping the ward#170 comment-thread shadow. That inversion breaks the read⊂write⊂admin contract, so I re-declared each action in its natural tier and every tier above. That is the main judgment call worth a second look.

Two things fought back mildly. The live monolith had drifted past the frozen snapshot (no ci-watch, comment-issue is a shadow action now, plus search user/topic, org-member, delete issue-comment, commit/branch/tag), so I took the current monolith as source of truth per the "admin matches leaf-for-leaf" acceptance. It does: 56 verbs each, empty diff. And the substrate spec bundle differs from what is committed here, so a naive make build-ward-kdl-forgejo-tiers corrupts the monolith with bundle drift. I built a temp bundle from committed HEAD so the copy is a no-op and only the 9 tier files changed.

Confidence is high. Build green, full test suite green, all pre-commit green, and I verified the subset shape directly: admin = write + the 5 delete leaves, write is a strict superset of read, read is get/list/search/view only, and all 10 denials render identically in every tier (never propagates up, exactly as your cli-guard#169 note said). Your Q1/Q2 confirmation matched what I landed, and the write-tier env-auth override (ward#331) is preserved since the broker path #329 consumes it.

One follow-up worth filing: the action re-declaration duplicates the ~80-line list-all-issue block across all three tiers. Phase 2 (monolith retirement) or a future cli-guard action-inherit feature could collapse that.

WARD-OUTCOME: done - forgejo read/write/admin tier guardfiles now carry the curated monolith surface (explicit leaves, all denials, op-pins, toggles, four actions); admin diffs empty against the monolith, landed on main. Honest retro: well-specified issue, and the clone genuinely had everything I needed, so most of this was careful transcription rather than discovery. The one real fork was open Q3 (do actions inherit?). cli-guard v0.72.0 says `action` blocks are child-local, and the issue's draft map placed each action in a single tier - but following that literally would make a write-tier `view issue` weaker than read's, dropping the ward#170 comment-thread shadow. That inversion breaks the read⊂write⊂admin contract, so I re-declared each action in its natural tier and every tier above. That is the main judgment call worth a second look. Two things fought back mildly. The live monolith had drifted past the frozen snapshot (no ci-watch, comment-issue is a shadow action now, plus search user/topic, org-member, delete issue-comment, commit/branch/tag), so I took the current monolith as source of truth per the "admin matches leaf-for-leaf" acceptance. It does: 56 verbs each, empty diff. And the substrate spec bundle differs from what is committed here, so a naive `make build-ward-kdl-forgejo-tiers` corrupts the monolith with bundle drift. I built a temp bundle from committed HEAD so the copy is a no-op and only the 9 tier files changed. Confidence is high. Build green, full test suite green, all pre-commit green, and I verified the subset shape directly: admin = write + the 5 delete leaves, write is a strict superset of read, read is get/list/search/view only, and all 10 denials render identically in every tier (never propagates up, exactly as your cli-guard#169 note said). Your Q1/Q2 confirmation matched what I landed, and the write-tier env-auth override (ward#331) is preserved since the broker path #329 consumes it. One follow-up worth filing: the action re-declaration duplicates the ~80-line list-all-issue block across all three tiers. Phase 2 (monolith retirement) or a future cli-guard action-inherit feature could collapse that.
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/ward#278
No description provided.