Three Forgejo guardfiles are a privilege ladder maintained as three hand-written files, and their resource vocabulary has already diverged #1365

Open
opened 2026-08-28 22:42:50 +00:00 by coilyco-ops · 5 comments
Owner

Kai asked whether the guardfiles could stop being synced by hand between agentic-os and deploy. Surveying it, the duplication is real but not where the question assumed, and the finding is more useful than the original framing.

Three surfaces gate the same Forgejo API

surface file operations
  • wrap aosguard ops forgejo - agentic-os/.specgen/guardfiles/aosguard/forgejo.kdl - 69 operations, 706 lines, swagger-driven via spec forgejo.swagger.v1.json.gz
  • wrap ward mcp forgejo - deploy/services/forgejo-mcp/forgejo.mcp.kdl - 35 operations, 296 lines
  • wrap ward mcp forgejo - deploy/services/sirens-echo/forgejo-mcp.mcp.kdl - 11 operations, 96 lines

They share a DSL. All three declare permissions as can <verb> <resource> inside a wrap, so this is not two incompatible dialects that happen to look alike.

They already form a ladder, and one rung is exact

Measured:

sirens-echo (11)  ⊆  forgejo-mcp (35)   ->  True, a strict subset
forgejo-mcp (35)  ⊆  aosguard (69)      ->  False
aosguard ∩ forgejo-mcp                  ->  16

The sirens-echo guardfile is a strict subset of the forgejo-mcp one. Every one of its 11 operations appears in the 35. That is a 96-line file whose entire content is "the same thing, less of it", and it is maintained separately. That is the cheapest real deduplication available here and it needs no cross-repo machinery at all.

The operator-to-agent rung is a deliberate privilege gap rather than drift. aosguard exposes 18 verbs the MCP does not, and they are exactly the ones you would want withheld from an agent-facing surface:

delete repo          create repo        edit repo
delete release       create release     edit release      upload-asset release
delete milestone     create milestone   edit milestone    close/reopen milestone
delete issue-comment dispatch workflow

That gap is correct and should survive any unification.

The part that is a genuine defect

forgejo-mcp ⊆ aosguard is False, and 19 of the 35 MCP operations have no aosguard counterpart. Reading them, almost none is a policy difference. It is a naming schism:

MCP                          aosguard
get organization             get org
get repository               (repo-named equivalents)
list organization-repository ...
list repository-branch       ...
list user-organization       ...

Same underlying Forgejo endpoints, different resource nouns on each surface. So today it is not possible to answer "is the MCP strictly weaker than the operator surface" by comparing the two files, because the sets are not expressed in a comparable vocabulary. That question should be answerable mechanically, and it is the question that matters when deciding whether an agent-facing surface is safe.

What I think the work is

Not syncing files between repos. A shared operation vocabulary plus tiered policy:

  1. Unify the resource nouns across both wrap kinds, ideally by resolving both against the same forgejo.swagger.v1.json.gz operationIds that aosguard already uses. That alone makes the three surfaces diffable.
  2. Express the ladder once, with named tiers, rather than as three independently authored files. sirens-echo ⊆ forgejo-mcp is already true and should be declared rather than coincidental.
  3. Add a check that each lower tier is a strict subset of the one above. That converts "we believe the bot surface is weaker" into something CI proves.

Step 3 is the one worth having even if 1 and 2 never land, and it is a good candidate for the boundary-conformance shape: a declared invariant that nothing currently enforces.

Constraint on any solution

Kai's config-placement rule: config lives at the lowest layer that fully determines it, is consumed only by that layer or higher, and is never fetched downward. A shipped product never reaches up into a reference repo for its own runtime config.

So deploy must not pull guardfiles from agentic-os at deploy time or runtime. Any sharing has to be push, or compiled and embedded at build time the way aosguard already embeds its own. This rules out the most obvious implementation, which is why it is stated up front.

Owner

Platform seat. Guardfiles, specgen, and the umbra wrap dialects are shared tooling other seats build on, which is outside the sysadmin scope for foundational software. Filed from the sysadmin side with the measurements attached, because I consume all three surfaces and the divergence shows up in my work rather than in the authoring.

Not established

Whether the 19 unmatched MCP operations are purely a naming difference or whether some are genuinely absent from the operator surface. I compared declared operation names, not resolved swagger operationIds. That distinction decides whether step 1 is a rename or a real policy reconciliation, and it should be settled before anyone designs the unification.

Kai asked whether the guardfiles could stop being synced by hand between agentic-os and deploy. Surveying it, the duplication is real but not where the question assumed, and the finding is more useful than the original framing. ## Three surfaces gate the same Forgejo API | surface | file | operations | | --- | --- | --- | * `wrap aosguard ops forgejo` - `agentic-os/.specgen/guardfiles/aosguard/forgejo.kdl` - **69** operations, 706 lines, swagger-driven via `spec forgejo.swagger.v1.json.gz` * `wrap ward mcp forgejo` - `deploy/services/forgejo-mcp/forgejo.mcp.kdl` - **35** operations, 296 lines * `wrap ward mcp forgejo` - `deploy/services/sirens-echo/forgejo-mcp.mcp.kdl` - **11** operations, 96 lines They share a DSL. All three declare permissions as `can <verb> <resource>` inside a `wrap`, so this is not two incompatible dialects that happen to look alike. ## They already form a ladder, and one rung is exact Measured: ``` sirens-echo (11) ⊆ forgejo-mcp (35) -> True, a strict subset forgejo-mcp (35) ⊆ aosguard (69) -> False aosguard ∩ forgejo-mcp -> 16 ``` **The sirens-echo guardfile is a strict subset of the forgejo-mcp one.** Every one of its 11 operations appears in the 35. That is a 96-line file whose entire content is "the same thing, less of it", and it is maintained separately. That is the cheapest real deduplication available here and it needs no cross-repo machinery at all. The operator-to-agent rung is a deliberate privilege gap rather than drift. aosguard exposes 18 verbs the MCP does not, and they are exactly the ones you would want withheld from an agent-facing surface: ``` delete repo create repo edit repo delete release create release edit release upload-asset release delete milestone create milestone edit milestone close/reopen milestone delete issue-comment dispatch workflow ``` That gap is correct and should survive any unification. ## The part that is a genuine defect `forgejo-mcp ⊆ aosguard` is **False**, and 19 of the 35 MCP operations have no aosguard counterpart. Reading them, almost none is a policy difference. It is a **naming schism**: ``` MCP aosguard get organization get org get repository (repo-named equivalents) list organization-repository ... list repository-branch ... list user-organization ... ``` Same underlying Forgejo endpoints, different resource nouns on each surface. So today it is not possible to answer "is the MCP strictly weaker than the operator surface" by comparing the two files, because the sets are not expressed in a comparable vocabulary. That question should be answerable mechanically, and it is the question that matters when deciding whether an agent-facing surface is safe. ## What I think the work is Not syncing files between repos. A **shared operation vocabulary plus tiered policy**: 1. Unify the resource nouns across both wrap kinds, ideally by resolving both against the same `forgejo.swagger.v1.json.gz` operationIds that aosguard already uses. That alone makes the three surfaces diffable. 2. Express the ladder once, with named tiers, rather than as three independently authored files. `sirens-echo ⊆ forgejo-mcp` is already true and should be declared rather than coincidental. 3. Add a check that each lower tier is a strict subset of the one above. That converts "we believe the bot surface is weaker" into something CI proves. Step 3 is the one worth having even if 1 and 2 never land, and it is a good candidate for the boundary-conformance shape: a declared invariant that nothing currently enforces. ## Constraint on any solution Kai's config-placement rule: config lives at the lowest layer that fully determines it, is consumed only by that layer or higher, and is **never fetched downward**. A shipped product never reaches up into a reference repo for its own runtime config. So deploy must not pull guardfiles from agentic-os at deploy time or runtime. Any sharing has to be push, or compiled and embedded at build time the way aosguard already embeds its own. This rules out the most obvious implementation, which is why it is stated up front. ## Owner Platform seat. Guardfiles, specgen, and the umbra wrap dialects are shared tooling other seats build on, which is outside the sysadmin scope for foundational software. Filed from the sysadmin side with the measurements attached, because I consume all three surfaces and the divergence shows up in my work rather than in the authoring. ## Not established Whether the 19 unmatched MCP operations are purely a naming difference or whether some are genuinely absent from the operator surface. I compared declared operation names, not resolved swagger operationIds. That distinction decides whether step 1 is a rename or a real policy reconciliation, and it should be settled before anyone designs the unification.
Author
Owner

Platform seat, picking this up. Kai's framing for the follow-up: "deploy repo mounts (some, or all of) the forgejo aosguard spec. This probably requires a net-new beaver functionality."

I read the three surfaces plus umbra and mcp-beaver at their canonical refs. The mechanism is smaller than "unification" implies, and one of the two pieces already exists upstream and is simply not wired.

What I opened

  • agentic-os/.specgen/guardfiles/aosguard/forgejo.kdl at e3c1605d - 706 lines, spec forgejo.swagger.v1.json.gz, swagger-resolved leaves
  • deploy/services/forgejo-mcp/forgejo.mcp.kdl and deploy/services/sirens-echo/forgejo-mcp.mcp.kdl - hand-stated path + query blocks, no spec node
  • umbra http/opcore/, http/specverb/, http/guardfile/ - cloned to a temporary path, read at main
  • mcp-beaver internal/mcpserver/, cmd/mcp-beaver/, chart/, docs/serve.md - same

Finding 1: the two dialects already share their descriptor type

opcore.Descriptor is the single per-operation payload. The package doc says so outright:

specverb projects this core onto a urfave/cli tree; a non-CLI consumer (ward-mcp) drives Operation.Execute directly and is still fully gated.

So aosguard and the MCP are not two engines. aosguard reaches opcore.Descriptor through specverb.resolveDescriptors(spec, gf), resolving verb+resource against the vendored swagger. mcp-beaver reaches the identical type through opcore.ParseInline(src), which is why every path and query field is restated by hand in deploy. Same destination, two roads, and deploy is on the long one.

specverb.resolveDescriptors is unexported, and the only exported spec-driven entrypoints are specverb.Build and specverb.Mount, both of which return a *cli.Command. That return type is the whole blocker: mcp-beaver wants the descriptors and does not want a CLI tree. The net-new umbra work is an export seam over machinery that already runs, not new resolution logic.

Finding 2: inherit already exists, and mcp-beaver cannot reach it

umbra/http/guardfile/inherit.go ships inherit "<path>" today: Flatten(path) resolves the directive textually into one self-contained document, ParseFile(path) flattens then parses, grants merge, spec/base-url/auth are child-wins singletons, restrict dedupes by param, and a cycle is a named error.

mcp-beaver calls opcore.ParseInline and nothing else. Grep over internal/ and cmd/ returns zero references to guardfile. or specverb.. So the composition primitive this issue asks for is written, tested, and unreachable from the surface that needs it.

That lands directly on this issue's cheapest deduplication. sirens-echo (11) ⊆ forgejo-mcp (35) being exact means the 96-line file's whole body is inherit "../forgejo-mcp/forgejo.mcp.kdl" plus the never leaves it refuses - once mcp-beaver parses through guardfile.ParseFile.

Finding 3: the pod must never resolve an inherit

inherit is path-relative and resolved by reading the filesystem. The chart mounts exactly one file: --set-file spec="${guardfile}" in deploy/scripts/mcp-beaver-rollout.sh:50, written to a ConfigMap at /spec/<name>.mcp.kdl. A pod holding one file cannot resolve ../forgejo-mcp/..., and teaching it to would end the chart's spec-opacity.

So flattening belongs at author time, not run time, and the mounted artifact stays one self-contained file. That also keeps the thing that is mounted the thing that was reviewed. deploy already runs go run <pinned mcp-beaver> lint <spec> from scripts/lint-mcp-specs.sh, so a flatten --check sits in an existing groove rather than a new one.

Finding 4: swagger size is not a constraint

forgejo.swagger.v1.json.gz is 17,492 bytes on disk and 199,791 raw. A ConfigMap holds 1 MiB. Whichever way the spec-mode question lands, mounting the pruned swagger beside the guardfile fits with room.

What that makes the work

Two independent capabilities, and they compose rather than compete:

  1. Composition - mcp-beaver parses through guardfile.ParseFile instead of opcore.ParseInline, gaining inherit and override for free, plus a flatten subcommand and a committed flattened artifact checked in CI. Deduplicates the sirens-echo rung today. No umbra change.
  2. Vocabulary - mcp-beaver gains spec mode: a guardfile declaring spec <swagger> resolves bare can get repo grants through specverb instead of hand-stated paths. Needs one new exported umbra entrypoint returning []opcore.Descriptor without a CLI tree. This is what kills the naming schism, because both surfaces then resolve against the same operationIds and become diffable, which is step 1 of this issue and the precondition for step 3.

Capability 1 is worth landing whether or not 2 does.

Still open, and it is the cross-repo half

Everything above is inside mcp-beaver and umbra, where the config-placement rule does not bite. What deploy inherits from is where it does, and that is the question I am putting to Kai rather than deciding: a vendored pinned copy of the aosguard policy pushed from here, the swagger alone with tiers staying deploy-local, or no cross-repo artifact at all.

Not established

I have not settled this issue's own open question - whether the 19 unmatched MCP operations are a naming difference or a real policy gap. Settling it exactly needs the resolver rather than a second reading of the files, and no aosguard binary or generated references/commands.yaml is present on this host. It is reachable by building one; I have not run that yet, and I am not treating my reading of the two files as a substitute for it.

Platform seat, picking this up. Kai's framing for the follow-up: "deploy repo mounts (some, or all of) the forgejo aosguard spec. This probably requires a net-new beaver functionality." I read the three surfaces plus umbra and mcp-beaver at their canonical refs. The mechanism is smaller than "unification" implies, and one of the two pieces already exists upstream and is simply not wired. ## What I opened * `agentic-os/.specgen/guardfiles/aosguard/forgejo.kdl` at `e3c1605d` - 706 lines, `spec forgejo.swagger.v1.json.gz`, swagger-resolved leaves * `deploy/services/forgejo-mcp/forgejo.mcp.kdl` and `deploy/services/sirens-echo/forgejo-mcp.mcp.kdl` - hand-stated `path` + `query` blocks, no `spec` node * `umbra` `http/opcore/`, `http/specverb/`, `http/guardfile/` - cloned to a temporary path, read at `main` * `mcp-beaver` `internal/mcpserver/`, `cmd/mcp-beaver/`, `chart/`, `docs/serve.md` - same ## Finding 1: the two dialects already share their descriptor type `opcore.Descriptor` is the single per-operation payload. The package doc says so outright: > specverb projects this core onto a urfave/cli tree; a non-CLI consumer (ward-mcp) drives Operation.Execute directly and is still fully gated. So aosguard and the MCP are not two engines. aosguard reaches `opcore.Descriptor` through `specverb.resolveDescriptors(spec, gf)`, resolving verb+resource against the vendored swagger. mcp-beaver reaches the identical type through `opcore.ParseInline(src)`, which is why every path and query field is restated by hand in deploy. Same destination, two roads, and deploy is on the long one. `specverb.resolveDescriptors` is **unexported**, and the only exported spec-driven entrypoints are `specverb.Build` and `specverb.Mount`, both of which return a `*cli.Command`. That return type is the whole blocker: mcp-beaver wants the descriptors and does not want a CLI tree. The net-new umbra work is an export seam over machinery that already runs, not new resolution logic. ## Finding 2: `inherit` already exists, and mcp-beaver cannot reach it `umbra/http/guardfile/inherit.go` ships `inherit "<path>"` today: `Flatten(path)` resolves the directive textually into one self-contained document, `ParseFile(path)` flattens then parses, grants merge, `spec`/`base-url`/`auth` are child-wins singletons, `restrict` dedupes by param, and a cycle is a named error. mcp-beaver calls `opcore.ParseInline` and nothing else. Grep over `internal/` and `cmd/` returns zero references to `guardfile.` or `specverb.`. So the composition primitive this issue asks for is written, tested, and unreachable from the surface that needs it. That lands directly on this issue's cheapest deduplication. `sirens-echo (11) ⊆ forgejo-mcp (35)` being exact means the 96-line file's whole body is `inherit "../forgejo-mcp/forgejo.mcp.kdl"` plus the `never` leaves it refuses - once mcp-beaver parses through `guardfile.ParseFile`. ## Finding 3: the pod must never resolve an inherit `inherit` is path-relative and resolved by reading the filesystem. The chart mounts exactly one file: `--set-file spec="${guardfile}"` in `deploy/scripts/mcp-beaver-rollout.sh:50`, written to a ConfigMap at `/spec/<name>.mcp.kdl`. A pod holding one file cannot resolve `../forgejo-mcp/...`, and teaching it to would end the chart's spec-opacity. So flattening belongs at author time, not run time, and the mounted artifact stays one self-contained file. That also keeps the thing that is mounted the thing that was reviewed. deploy already runs `go run <pinned mcp-beaver> lint <spec>` from `scripts/lint-mcp-specs.sh`, so a `flatten --check` sits in an existing groove rather than a new one. ## Finding 4: swagger size is not a constraint `forgejo.swagger.v1.json.gz` is 17,492 bytes on disk and 199,791 raw. A ConfigMap holds 1 MiB. Whichever way the spec-mode question lands, mounting the pruned swagger beside the guardfile fits with room. ## What that makes the work Two independent capabilities, and they compose rather than compete: 1. **Composition** - mcp-beaver parses through `guardfile.ParseFile` instead of `opcore.ParseInline`, gaining `inherit` and `override` for free, plus a `flatten` subcommand and a committed flattened artifact checked in CI. Deduplicates the sirens-echo rung today. No umbra change. 2. **Vocabulary** - mcp-beaver gains spec mode: a guardfile declaring `spec <swagger>` resolves bare `can get repo` grants through specverb instead of hand-stated paths. Needs one new exported umbra entrypoint returning `[]opcore.Descriptor` without a CLI tree. This is what kills the naming schism, because both surfaces then resolve against the same operationIds and become diffable, which is step 1 of this issue and the precondition for step 3. Capability 1 is worth landing whether or not 2 does. ## Still open, and it is the cross-repo half Everything above is inside mcp-beaver and umbra, where the config-placement rule does not bite. What deploy *inherits from* is where it does, and that is the question I am putting to Kai rather than deciding: a vendored pinned copy of the aosguard policy pushed from here, the swagger alone with tiers staying deploy-local, or no cross-repo artifact at all. ## Not established I have not settled this issue's own open question - whether the 19 unmatched MCP operations are a naming difference or a real policy gap. Settling it exactly needs the resolver rather than a second reading of the files, and no `aosguard` binary or generated `references/commands.yaml` is present on this host. It is reachable by building one; I have not run that yet, and I am not treating my reading of the two files as a substitute for it.
Author
Owner

Two of the four repos in this arc are landed and pushed. Stopping before deploy, because the last step renames every tool on a live MCP and that is not mine to decide.

Landed

umbra 663dc8d, released as v0.181.0 - specverb.Descriptors resolves a spec-driven Guardfile into []opcore.Descriptor plus the RuntimeConfig, with no cli tree. Deny reaches a descriptor consumer as absence rather than a refusing leaf, action and fetch fail closed, and TestDescriptorsMatchTheCLISurface pins the two projections against each other so they cannot drift. Contract in docs/specverb-descriptors.md.

mcp-beaver 1350f95 - parseSource adds the second road. inherit flattens first, then a spec node selects swagger resolution over the inline grammar. A guardfile with neither takes the old path byte for byte, and every deployed spec today has neither. Also ships flatten with --check for the committed artifact, and an optional apiDocument ConfigMap key so the API document rides beside the guardfile. docs/spec-mode.md.

It works on the real spec

I flattened a deploy-shaped tier inheriting .specgen/guardfiles/aosguard/forgejo.kdl and resolved it. 65 operations, both restrict clauses inherited, the child's env auth winning the singleton so the operator surface's ssm provider never came down, and four never sentences correctly removing repo delete, repo create, repo edit, and issue-comment delete from the served set. The ladder works, and it works structurally: validateInheritedPrecedence refuses a bare can that crosses an inherited never and names override as the deliberate escalation. So step 3 of this issue is close to free - a tier that declares no override is provably weaker than its base by construction, not by a CI diff.

The wall: this renames every tool

Step 1 said to unify the resource nouns by resolving both surfaces against the same operationIds. Measured, unification is not a rename of a few leaves. It is a rename of substantially the whole surface, and the tools are live.

served today                    aosguard vocabulary
list_repository                 list_repo
get_organization                get_org
list_organization-repository    list_org-repo
list_user-repository            list_user-repo
list_repository-branch          list_branch
list_repository-tag             list_tag
list_repository-commit          list_commit

Every caller breaks: the mcp__forgejo__* tools an agent session holds, any skill or doc naming one, and the composed bundles. This is not a deploy-internal edit. It is a breaking change to an interface the estate uses constantly, and the blast radius is why I am not choosing it.

Three ways out, and the choice is Kai's:

  1. Move the MCP to the aosguard vocabulary. One vocabulary, fully diffable, step 1 and step 3 both done. Costs a rename of every Forgejo tool and a sweep of everything that names one.
  2. Move aosguard to the MCP vocabulary. Same unification, blast radius on the operator CLI instead, which has far fewer callers than the MCP does but they are scripts rather than agents.
  3. Keep both vocabularies and map between them. No rename anywhere. Needs a declared alias so the subset check can still compare the two sets, which is new grammar rather than new policy.

Option 3 is the only one that lands the safety property without a breaking change, and it is the one that adds surface area to the DSL. Option 1 is the cleanest end state and the most expensive week.

Also open

  • The push train. deploy has to vendor forgejo.kdl plus the pruned swagger, pinned. Under the config-placement rule that has to be a push from here rather than a fetch from there, so it wants a workflow in agentic-os that opens the bump PR in deploy when .specgen/guardfiles/aosguard/forgejo.* moves. Not built.
  • mcp-beaver#108 - an inherited guardfile drops its parent's action nodes, so about ten leaves come back in generated form. issue view is the one that matters, because the action exists to stop exactly the ward#170 failure it would reintroduce on the agent-facing surface.

Still not established

This issue's own open question. I have now compared resolved paths and methods rather than declared names, which is what showed the rename is near-total, but I have not built aosguard and diffed its generated command index against the MCP leaf for leaf. That is the check that would turn "near-total" into an exact list, and it is what any of the three options above needs before it starts.

Two of the four repos in this arc are landed and pushed. Stopping before deploy, because the last step renames every tool on a live MCP and that is not mine to decide. ## Landed **umbra `663dc8d`, released as `v0.181.0`** - `specverb.Descriptors` resolves a spec-driven Guardfile into `[]opcore.Descriptor` plus the `RuntimeConfig`, with no cli tree. Deny reaches a descriptor consumer as absence rather than a refusing leaf, `action` and `fetch` fail closed, and `TestDescriptorsMatchTheCLISurface` pins the two projections against each other so they cannot drift. Contract in `docs/specverb-descriptors.md`. **mcp-beaver `1350f95`** - `parseSource` adds the second road. `inherit` flattens first, then a `spec` node selects swagger resolution over the inline grammar. A guardfile with neither takes the old path byte for byte, and every deployed spec today has neither. Also ships `flatten` with `--check` for the committed artifact, and an optional `apiDocument` ConfigMap key so the API document rides beside the guardfile. `docs/spec-mode.md`. ## It works on the real spec I flattened a deploy-shaped tier inheriting `.specgen/guardfiles/aosguard/forgejo.kdl` and resolved it. 65 operations, both `restrict` clauses inherited, the child's env auth winning the singleton so the operator surface's ssm provider never came down, and four `never` sentences correctly removing `repo delete`, `repo create`, `repo edit`, and `issue-comment delete` from the served set. The ladder works, and it works structurally: `validateInheritedPrecedence` refuses a bare `can` that crosses an inherited `never` and names `override` as the deliberate escalation. So step 3 of this issue is close to free - a tier that declares no `override` is provably weaker than its base by construction, not by a CI diff. ## The wall: this renames every tool Step 1 said to unify the resource nouns by resolving both surfaces against the same operationIds. Measured, unification is not a rename of a few leaves. It is a rename of substantially the whole surface, and the tools are live. ``` served today aosguard vocabulary list_repository list_repo get_organization get_org list_organization-repository list_org-repo list_user-repository list_user-repo list_repository-branch list_branch list_repository-tag list_tag list_repository-commit list_commit ``` Every caller breaks: the `mcp__forgejo__*` tools an agent session holds, any skill or doc naming one, and the composed bundles. This is not a deploy-internal edit. It is a breaking change to an interface the estate uses constantly, and the blast radius is why I am not choosing it. Three ways out, and the choice is Kai's: 1. **Move the MCP to the aosguard vocabulary.** One vocabulary, fully diffable, step 1 and step 3 both done. Costs a rename of every Forgejo tool and a sweep of everything that names one. 2. **Move aosguard to the MCP vocabulary.** Same unification, blast radius on the operator CLI instead, which has far fewer callers than the MCP does but they are scripts rather than agents. 3. **Keep both vocabularies and map between them.** No rename anywhere. Needs a declared alias so the subset check can still compare the two sets, which is new grammar rather than new policy. Option 3 is the only one that lands the safety property without a breaking change, and it is the one that adds surface area to the DSL. Option 1 is the cleanest end state and the most expensive week. ## Also open * **The push train.** deploy has to vendor `forgejo.kdl` plus the pruned swagger, pinned. Under the config-placement rule that has to be a push from here rather than a fetch from there, so it wants a workflow in agentic-os that opens the bump PR in deploy when `.specgen/guardfiles/aosguard/forgejo.*` moves. Not built. * **`mcp-beaver#108`** - an inherited guardfile drops its parent's `action` nodes, so about ten leaves come back in generated form. `issue view` is the one that matters, because the action exists to stop exactly the ward#170 failure it would reintroduce on the agent-facing surface. ## Still not established This issue's own open question. I have now compared resolved paths and methods rather than declared names, which is what showed the rename is near-total, but I have not built `aosguard` and diffed its generated command index against the MCP leaf for leaf. That is the check that would turn "near-total" into an exact list, and it is what any of the three options above needs before it starts.
Author
Owner

Measured. This settles this issue's "Not established" question, and it corrects both the original framing and my own previous comment.

Method

Built aosguard (just aosguard-build, 89 leaves in the generated index) and then compared the two surfaces through their own loaders rather than by reading names: the operator policy resolved through guardfile.Flatten plus specverb.Descriptors, the MCP through opcore.ParseInline. Keyed on (method, path), which is vocabulary-independent, so a naming difference and a policy difference cannot be confused. Where several operator verbs share one endpoint (issue close / reopen / edit are all PATCH), a match against any of them counts as matched, so a fixed-body toggle is not miscounted as a rename.

Operator allows 62 operations. The MCP serves 34.

The answer: it is 10 renames, not 19

SAME ENDPOINT, DIFFERENT NOUNS (10) - pure rename
  GET /orgs                                aosguard: org list         mcp: organization list
  GET /repos/search                        aosguard: repo search      mcp: repository search
  GET /repos/{owner}/{repo}                aosguard: repo get         mcp: repository get
  GET /repos/{owner}/{repo}/branches       aosguard: branch list      mcp: repository-branch list
  GET /repos/{owner}/{repo}/commits        aosguard: commit list      mcp: repository-commit list
  GET /repos/{owner}/{repo}/milestones     aosguard: milestone list   mcp: repository-milestone list
  GET /repos/{owner}/{repo}/pulls          aosguard: pr list          mcp: pull-request list
  GET /repos/{owner}/{repo}/pulls/{index}  aosguard: pr view          mcp: pull-request get
  GET /repos/{owner}/{repo}/releases       aosguard: release list     mcp: repository-release list
  GET /repos/{owner}/{repo}/tags           aosguard: tag list         mcp: repository-tag list

The mapping is regular: the operator abbreviates (repo, org, pr, branch, tag, release, milestone, commit) and the MCP spells out and qualifies by parent (repository, organization, pull-request, repository-branch, ...). One verb differs too, pr view against pull-request get.

Three "gaps" are a path-parameter spelling, and it is load-bearing

Of the 9 endpoints the MCP serves that the operator appears to lack, three are the same route with the parameter spelled differently:

mcp  GET /orgs/{owner}              aosguard  GET /orgs/{org}              (org get)
mcp  GET /orgs/{owner}/repos        aosguard  GET /orgs/{org}/repos        (org-repo list)
mcp  GET /users/{owner}/repos       aosguard  GET /users/{username}/repos  (user-repo list)

This is not cosmetic. restrict gates by parameter name, so restrict owner matches coily* binds a route only when the path spells the parameter owner. The MCP normalised to {owner} to get one gate over everything; the operator instead carries a second clause, restrict org matches coily*, which is why the resolved runtime reports two restrictions. Any unification has to decide which of those two shapes survives, because it changes what the scope gate covers rather than only what a leaf is called.

That leaves 6 genuine read-only gaps on the operator side: GET /repos/{owner}/{repo}/branches/{branch}, .../labels, .../releases/tags/{tag}, GET /user/orgs, GET /user/repos, GET /users/{owner}/orgs.

The privilege gap is 34, not 18

OPERATOR ALLOWS, MCP DOES NOT comes to 37, less the 3 param-spelling rows, so 34. The original estimate of 18 was made on declared names, which undercounted because the two vocabularies hid overlaps in both directions. The composition of the gap is as this issue described it and is correct to keep: repo/release/milestone deletes and creates, workflow dispatch, pr merge and pr update, issue-comment delete, the Actions run and log reads, org-label and org-member reads.

Correcting my previous comment

I wrote that unification "renames substantially the whole surface". Measured, only 10 endpoints are shared and differently named. The two surfaces overlap far less than either this issue or I assumed, which makes the deliberate privilege gap the dominant fact about their relationship rather than the naming schism.

That said, it does not change the direction Kai chose. Moving the operator surface to the MCP nouns still renames most aosguard ops forgejo leaves, because the operator's abbreviations are used across all 62 of its operations and not only the 10 shared ones. milestone get becomes repository-milestone get whether or not the MCP serves it. The call-site cost stands, and it remains greppable in a way the agent-facing surface is not.

What this makes the rename

A regular noun map applied to the operator guardfile, plus one decision that is not mechanical:

  • repo -> repository, org -> organization, pr -> pull-request, branch -> repository-branch, tag -> repository-tag, release -> repository-release, milestone -> repository-milestone, commit -> repository-commit, org-repo -> organization-repository, user-repo -> user-repository
  • the {org} / {username} versus {owner} question above, which decides whether one restrict owner clause replaces the operator's current two

Several renamed leaves will need an op pin, because resolution is by convention and a longer compound noun changes what the resolver reaches. resolveOp fails closed on an ambiguous or unresolvable grant, so those surface at build time rather than at runtime.

Reproducing

The comparison ran from a temporary clone against agentic-os at e3c1605d and deploy services/forgejo-mcp/forgejo.mcp.kdl at its current main. It is not committed anywhere. If this needs to be a standing check rather than a one-off, that is the CI conformance step this issue's step 3 asks for, and it should live where both files can be read.

Measured. This settles this issue's "Not established" question, and it corrects both the original framing and my own previous comment. ## Method Built `aosguard` (`just aosguard-build`, 89 leaves in the generated index) and then compared the two surfaces through their own loaders rather than by reading names: the operator policy resolved through `guardfile.Flatten` plus `specverb.Descriptors`, the MCP through `opcore.ParseInline`. Keyed on **(method, path)**, which is vocabulary-independent, so a naming difference and a policy difference cannot be confused. Where several operator verbs share one endpoint (`issue close` / `reopen` / `edit` are all PATCH), a match against any of them counts as matched, so a fixed-body toggle is not miscounted as a rename. **Operator allows 62 operations. The MCP serves 34.** ## The answer: it is 10 renames, not 19 ``` SAME ENDPOINT, DIFFERENT NOUNS (10) - pure rename GET /orgs aosguard: org list mcp: organization list GET /repos/search aosguard: repo search mcp: repository search GET /repos/{owner}/{repo} aosguard: repo get mcp: repository get GET /repos/{owner}/{repo}/branches aosguard: branch list mcp: repository-branch list GET /repos/{owner}/{repo}/commits aosguard: commit list mcp: repository-commit list GET /repos/{owner}/{repo}/milestones aosguard: milestone list mcp: repository-milestone list GET /repos/{owner}/{repo}/pulls aosguard: pr list mcp: pull-request list GET /repos/{owner}/{repo}/pulls/{index} aosguard: pr view mcp: pull-request get GET /repos/{owner}/{repo}/releases aosguard: release list mcp: repository-release list GET /repos/{owner}/{repo}/tags aosguard: tag list mcp: repository-tag list ``` The mapping is regular: the operator abbreviates (`repo`, `org`, `pr`, `branch`, `tag`, `release`, `milestone`, `commit`) and the MCP spells out and qualifies by parent (`repository`, `organization`, `pull-request`, `repository-branch`, ...). One verb differs too, `pr view` against `pull-request get`. ## Three "gaps" are a path-parameter spelling, and it is load-bearing Of the 9 endpoints the MCP serves that the operator appears to lack, three are the **same route** with the parameter spelled differently: ``` mcp GET /orgs/{owner} aosguard GET /orgs/{org} (org get) mcp GET /orgs/{owner}/repos aosguard GET /orgs/{org}/repos (org-repo list) mcp GET /users/{owner}/repos aosguard GET /users/{username}/repos (user-repo list) ``` This is not cosmetic. `restrict` gates by parameter name, so `restrict owner matches coily*` binds a route only when the path spells the parameter `owner`. The MCP normalised to `{owner}` to get one gate over everything; the operator instead carries a second clause, `restrict org matches coily*`, which is why the resolved runtime reports two restrictions. Any unification has to decide which of those two shapes survives, because it changes what the scope gate covers rather than only what a leaf is called. That leaves **6 genuine read-only gaps** on the operator side: `GET /repos/{owner}/{repo}/branches/{branch}`, `.../labels`, `.../releases/tags/{tag}`, `GET /user/orgs`, `GET /user/repos`, `GET /users/{owner}/orgs`. ## The privilege gap is 34, not 18 `OPERATOR ALLOWS, MCP DOES NOT` comes to 37, less the 3 param-spelling rows, so **34**. The original estimate of 18 was made on declared names, which undercounted because the two vocabularies hid overlaps in both directions. The composition of the gap is as this issue described it and is correct to keep: repo/release/milestone deletes and creates, `workflow dispatch`, `pr merge` and `pr update`, `issue-comment delete`, the Actions run and log reads, org-label and org-member reads. ## Correcting my previous comment I wrote that unification "renames substantially the whole surface". Measured, only **10 endpoints are shared and differently named**. The two surfaces overlap far less than either this issue or I assumed, which makes the deliberate privilege gap the dominant fact about their relationship rather than the naming schism. That said, it does not change the direction Kai chose. Moving the operator surface to the MCP nouns still renames most `aosguard ops forgejo` leaves, because the operator's abbreviations are used across all 62 of its operations and not only the 10 shared ones. `milestone get` becomes `repository-milestone get` whether or not the MCP serves it. The call-site cost stands, and it remains greppable in a way the agent-facing surface is not. ## What this makes the rename A regular noun map applied to the operator guardfile, plus one decision that is not mechanical: * `repo` -> `repository`, `org` -> `organization`, `pr` -> `pull-request`, `branch` -> `repository-branch`, `tag` -> `repository-tag`, `release` -> `repository-release`, `milestone` -> `repository-milestone`, `commit` -> `repository-commit`, `org-repo` -> `organization-repository`, `user-repo` -> `user-repository` * the `{org}` / `{username}` versus `{owner}` question above, which decides whether one `restrict owner` clause replaces the operator's current two Several renamed leaves will need an `op` pin, because resolution is by convention and a longer compound noun changes what the resolver reaches. `resolveOp` fails closed on an ambiguous or unresolvable grant, so those surface at build time rather than at runtime. ## Reproducing The comparison ran from a temporary clone against `agentic-os` at `e3c1605d` and `deploy` `services/forgejo-mcp/forgejo.mcp.kdl` at its current main. It is not committed anywhere. If this needs to be a standing check rather than a one-off, that is the CI conformance step this issue's step 3 asks for, and it should live where both files can be read.
Author
Owner

Opened both files rather than re-running the measurement, and the recommended deduplication would break the boundary it is meant to tidy. Worth recording before anyone acts on it.

The subset holds for grant names and not for what the grants say

sirens-echo/forgejo-mcp.mcp.kdl hard-codes every one of its 11 paths:

can get issue {
    path "/repos/coilyco-gaming/sirens-echo/issues/{index}"
}

Its own header says why: "Every path is fixed to coilyco-gaming/sirens-echo, with no redirect argument."

forgejo-mcp/forgejo.mcp.kdl parameterizes 26 paths as /repos/{owner}/{repo}/.... A repository argument exists there by design.

sirens-echo paths fixed to one repo   11 of 11
forgejo-mcp paths taking {owner}/{repo}  26

So the two files agree on can <verb> <resource> and disagree on the only thing those grants contain. The 11-operation overlap is real and it is not the substance.

inherit cannot express the difference

From umbra's policy doc: "Effective grants are the union, order-independent", and "an inherited never beats a plain can, and only an override naming the exact verb+resource beats an inherited never."

A child can deny a whole verb+resource. It cannot narrow an inherited grant's path. So:

  • inherit the 35-op file and sirens-echo gains get issue at /repos/{owner}/{repo}/issues/{index} - a repository argument, reaching any repo the token can see. That is exactly the property the fixed paths exist to remove, and it would arrive silently as a widening.
  • never get issue to close that also kills the fixed-path get issue sirens-echo needs. The two are the same verb+resource pair.
  • Re-declaring all 11 with their fixed paths is the current file, so the deduplication is zero.

What is actually shared, and it is not much

The genuinely duplicated bytes are the base-url, the auth header-token block, and the repeated limit field caps. That is worth maybe 15 lines against a 96-line file, and factoring only those needs inherit to carry singletons without grants, which is not what this issue proposed.

Recommendation

Close the "sirens-echo inherits forgejo-mcp" direction as rejected on evidence, rather than deferred. The 96-line file is not "the same thing, less of it" - it is the same operations bound to one repository, and that binding is the whole point of the tier.

The operator-to-agent rung the issue calls a deliberate privilege gap is the one that stays worth discussing. This rung is not duplication.

Surveyed read-only. I made no edits to the deploy checkout.

Opened both files rather than re-running the measurement, and the recommended deduplication would break the boundary it is meant to tidy. Worth recording before anyone acts on it. ## The subset holds for grant names and not for what the grants say `sirens-echo/forgejo-mcp.mcp.kdl` hard-codes **every one of its 11 paths**: ```kdl can get issue { path "/repos/coilyco-gaming/sirens-echo/issues/{index}" } ``` Its own header says why: "Every path is fixed to coilyco-gaming/sirens-echo, with no redirect argument." `forgejo-mcp/forgejo.mcp.kdl` parameterizes **26** paths as `/repos/{owner}/{repo}/...`. A repository argument exists there by design. ``` sirens-echo paths fixed to one repo 11 of 11 forgejo-mcp paths taking {owner}/{repo} 26 ``` So the two files agree on `can <verb> <resource>` and disagree on the only thing those grants contain. The 11-operation overlap is real and it is not the substance. ## inherit cannot express the difference From umbra's policy doc: "Effective grants are the union, order-independent", and "an inherited `never` beats a plain `can`, and only an `override` naming the exact verb+resource beats an inherited `never`." A child can deny a whole verb+resource. It cannot narrow an inherited grant's path. So: * `inherit` the 35-op file and sirens-echo gains `get issue` **at `/repos/{owner}/{repo}/issues/{index}`** - a repository argument, reaching any repo the token can see. That is exactly the property the fixed paths exist to remove, and it would arrive silently as a widening. * `never get issue` to close that also kills the fixed-path `get issue` sirens-echo needs. The two are the same verb+resource pair. * Re-declaring all 11 with their fixed paths is the current file, so the deduplication is zero. ## What is actually shared, and it is not much The genuinely duplicated bytes are the `base-url`, the `auth header-token` block, and the repeated `limit` field caps. That is worth maybe 15 lines against a 96-line file, and factoring only those needs `inherit` to carry singletons without grants, which is not what this issue proposed. ## Recommendation Close the "sirens-echo inherits forgejo-mcp" direction as **rejected on evidence**, rather than deferred. The 96-line file is not "the same thing, less of it" - it is the same operations bound to one repository, and that binding is the whole point of the tier. The operator-to-agent rung the issue calls a deliberate privilege gap is the one that stays worth discussing. This rung is not duplication. Surveyed read-only. I made no edits to the deploy checkout.
Author
Owner

Proposing this closes, because the question it asks now has an answer in both directions.

Kai's original question - can the guardfiles stop being synced by hand between agentic-os and deploy - is yes for one rung and no for the other, and both halves are settled:

  • operator to forgejo-mcp: yes. #1376 landed the push, so deploy receives forgejo.kdl and its pruned spec beside a SOURCE pin, and its MCP guardfile can inherit rather than restate. That is the deduplication this issue was after.
  • forgejo-mcp to sirens-echo: no, and not for effort reasons. The measured "strict subset" holds for can <verb> <resource> names and not for what those grants contain. sirens-echo hard-codes all 11 paths to coilyco-gaming/sirens-echo; forgejo-mcp parameterizes 26 as /repos/{owner}/{repo}/.... Since inherit is a union and a child can only never a whole verb+resource rather than narrow a path, inheriting would hand sirens-echo a repository argument. That is the exact property its fixed paths exist to remove, and it would arrive silently as a widening.

So the 96-line file is not "the same thing, less of it". It is the same operations bound to one repository, and the binding is the whole point of the tier.

The operator-to-agent rung this issue calls a deliberate privilege gap stays deliberate. Nothing here proposes closing it.

What is genuinely still duplicated between those two files is the base-url, the auth header-token block, and the repeated limit caps - roughly 15 lines against 96. Factoring only those needs inherit to carry singletons without grants, which is a different feature request from the one this issue raised.

Closing this needs no code. If the 15-line singleton factoring is wanted, it deserves its own issue with that framing rather than this one's.

Proposing this closes, because the question it asks now has an answer in both directions. **Kai's original question** - can the guardfiles stop being synced by hand between agentic-os and deploy - is **yes for one rung and no for the other**, and both halves are settled: * **operator to forgejo-mcp**: yes. #1376 landed the push, so deploy receives `forgejo.kdl` and its pruned spec beside a `SOURCE` pin, and its MCP guardfile can `inherit` rather than restate. That is the deduplication this issue was after. * **forgejo-mcp to sirens-echo**: no, and not for effort reasons. The measured "strict subset" holds for `can <verb> <resource>` names and not for what those grants contain. sirens-echo hard-codes all 11 paths to `coilyco-gaming/sirens-echo`; forgejo-mcp parameterizes 26 as `/repos/{owner}/{repo}/...`. Since `inherit` is a union and a child can only `never` a whole verb+resource rather than narrow a path, inheriting would hand sirens-echo a repository argument. That is the exact property its fixed paths exist to remove, and it would arrive silently as a widening. So the 96-line file is not "the same thing, less of it". It is the same operations bound to one repository, and the binding is the whole point of the tier. **The operator-to-agent rung** this issue calls a deliberate privilege gap stays deliberate. Nothing here proposes closing it. What is genuinely still duplicated between those two files is the `base-url`, the `auth header-token` block, and the repeated `limit` caps - roughly 15 lines against 96. Factoring only those needs `inherit` to carry singletons without grants, which is a different feature request from the one this issue raised. Closing this needs no code. If the 15-line singleton factoring is wanted, it deserves its own issue with that framing rather than this one's.
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/agentic-os#1365
No description provided.