options: a named membership set in the guardfile, so a large literal allowlist is not a list of globs #307

Open
opened 2026-08-19 08:26:21 +00:00 by coilyco-ops · 0 comments
Member

Kai's proposal, 2026-08-19, recorded by Saiya (exec seat). Sibling in shape to #306: both extend the frozen inline grammar because the thing wanted cannot be expressed by composing what is there.

The motivating case

A guardfile wrapping Wikipedia's REST API, restricted to a curated collection: the 999 articles on Wikipedia:Vital articles/Level 3. Measured today, that list is 999 titles, 14,133 bytes as a JSON array, and every title is a literal string.

Kai's position, which shapes this whole issue: a 999-line guardfile is fine here, and preferred. The list is the collection manifest. In the guardfile it is reviewable, diffable, and greppable, and a change to what the agent may reach is a change to the guard rather than to a data file somewhere else. This issue therefore does not propose sourcing the set from a file or a provider. The literals belong in the KDL.

What exists

restrict <param> matches "<glob>"..., parsed at http/guardfile/guardfile.go:792 and dispatched at http/opcore/inline.go:88. Wrap-level, fail-closed before any wire call, enforced on the action path, inherited deduped by param with the child winning.

It already does the enforcement half correctly, and I want to be clear that this issue is not filed because restrict is broken. A restrict title matches block with 999 entries would work today, at zero context cost, which is more than I initially credited it with.

What it does not do, in descending order of how much it matters

1. It is a glob matcher being asked to test membership.

Restriction is documented at guardfile.go:100 as taking globs, and the parse error at guardfile.go:797 says so. Every one of those 999 entries would be compiled and evaluated as a pattern.

I checked whether this actually bites for this list, and it does not: zero of the 999 titles contain *, ?, [, ], or a backslash. Twenty contain parentheses, which are not glob metacharacters. So for the motivating case this is a correctness non-issue and I am not going to pretend otherwise.

The argument is about the general case and about direction of failure. A metacharacter appearing in a literal makes a glob match more than the author wrote, which widens an allowlist. In a framework whose entire discipline is failing closed, the one construct that silently fails open on a typo is worth replacing with an exact-set test. It is also the kind of bug that is invisible in review, because the guardfile line looks exactly like the 998 correct ones next to it.

2. It is not nameable, so it cannot be shared or stated.

restrict binds a list to one {param} name. A wrap with two grants taking the same collection under different path-parameter names needs two copies of 999 lines, and nothing in the file says they are meant to be the same set. A named set is declared once and referenced.

3. Nothing can say what the set is.

There is no way for describe output, --help, or a tool schema to carry "restricted to the 999 Wikipedia vital articles". The choice today is silence or the whole list. For an MCP consumer that is the difference between an agent that knows it holds a bounded collection and one that discovers the boundary by being refused 999 times.

4. There is nowhere to hang schema emission.

Confirmed by sweep: umbra has no enum or schema-value-set concept anywhere. The only two occurrences of "enum" in docs/ are the English word "enumerate", in specverb-policy.md:27 and specverb-resolution.md:25. So there is currently no answer to "which values may this field take" beyond type and bounds.

Rough shape

Naming to be settled in review. The reference syntax below is the part I am least sure about.

options vital_articles {
    describe "The 999 articles on Wikipedia's Vital Articles Level 3 list."
    member "Earth"
    member "Human"
    // ... 997 more
}

wrap ward mcp encyclopedia {
    base-url "en.wikipedia.org"
    auth none
    restrict title in vital_articles
    can get article_summary {
        path "/api/rest_v1/page/summary/{title}"
    }
}

Properties worth having, whatever the spelling:

  • Exact membership. A set, not a pattern list. Hashable, order-independent, O(1) at call time rather than 999 glob evaluations.
  • Fail-closed parse. Empty set, duplicate member, empty member string, unknown child, a restrict ... in naming an undeclared set, and a declared set nothing references are all build errors. The last one matters: a 999-line block nobody uses is exactly the kind of thing that rots silently.
  • Composes with the existing form. restrict <param> matches "<glob>" keeps working untouched. This adds a second, stricter mode rather than reinterpreting the first, so no existing guardfile changes meaning.

The thing this does NOT solve, stated plainly

Discovery. A consumer still has no cheap way to learn what is in the set.

The obvious move is emitting the members as JSON Schema enum on the field. For a set this size that is the wrong default and probably the wrong option entirely. 14,133 bytes in a tool schema is paid on every turn carrying the roster, against measurements in coilyco-gaming/sirens-echo#932 of a 116 KB fixed prefix and #1002 of that prefix being paid roughly six times per reply. Emitting this set would be a measurable regression on a lane already being worked for exactly that.

So if schema emission is built at all it should be opt-in and default off, and the describe string is the real discovery mechanism: one sentence naming what the collection is, at roughly 60 bytes rather than 14,133. That is enough for an agent to describe its own boundary honestly, which is the capability-honesty requirement in coilyco-gaming/sirens-echo#200, and it is not enough for the agent to enumerate the shelf. Enumerating the shelf is a consumer-side problem and does not belong in this construct.

I would rather this issue land as enforcement plus a description and be honest that discovery is unsolved, than grow a schema-emission feature whose default costs every consumer context they did not ask for.

Open questions I am not deciding

  • Wrap-level or top-level. applyNode at inline.go:75 owns the wrap body and its frozen set is base-url | auth | header | restrict | can | proxy. A top-level options sibling to wrap reads better, but inherit resolves textually by splicing the wrap body per specverb-policy.md:23, so a top-level node may not survive inheritance. That interaction should decide the placement rather than aesthetics.
  • How a grant references a set. restrict <param> in <name> reuses the existing enforcement path. field "title" options=<name> would extend it to typed query fields, which restrict cannot reach at all today since it is bound to {param} path templates. The second is more useful and a larger change.
  • Whether describe auto-reports the count. "999 members" is derivable and useful, and it is also a number that goes stale in prose the moment someone adds a member.
  • A file-sourced form. Deliberately excluded per Kai's call above. Recorded here only so a future reader knows it was considered and rejected rather than missed.

Consumer waiting on it

coilyco-bridge/deploy#720 carries an epic that wants exactly this shape, and one child of it is an encyclopedia server whose whole design turns on a bounded curated collection. That work is not blocked, since restrict ... matches would carry it, but it would be authored twice if this lands afterward.

Complete when

  • A named literal set can be declared in a guardfile and referenced by an enforcing construct.
  • Membership is exact-string, with no pattern interpretation of any member.
  • The set carries a description reaching describe and --help.
  • Parsing fails closed on every case listed above, including an unreferenced set.
  • Existing restrict ... matches guardfiles are byte-for-byte unaffected.
  • Schema emission is either absent or opt-in and off by default, with the context cost recorded in the docs rather than discovered by a consumer.

Refs: #306 for the parallel frozen-grammar extension, coilyco-bridge/deploy#720.

**Kai's proposal, 2026-08-19, recorded by Saiya (exec seat).** Sibling in shape to #306: both extend the frozen inline grammar because the thing wanted cannot be expressed by composing what is there. ## The motivating case A guardfile wrapping Wikipedia's REST API, restricted to a curated collection: the 999 articles on `Wikipedia:Vital articles/Level 3`. Measured today, that list is **999 titles, 14,133 bytes as a JSON array**, and every title is a literal string. **Kai's position, which shapes this whole issue: a 999-line guardfile is fine here, and preferred.** The list is the collection manifest. In the guardfile it is reviewable, diffable, and greppable, and a change to what the agent may reach is a change to the guard rather than to a data file somewhere else. This issue therefore does **not** propose sourcing the set from a file or a provider. The literals belong in the KDL. ## What exists `restrict <param> matches "<glob>"...`, parsed at `http/guardfile/guardfile.go:792` and dispatched at `http/opcore/inline.go:88`. Wrap-level, fail-closed before any wire call, enforced on the action path, inherited deduped by param with the child winning. **It already does the enforcement half correctly**, and I want to be clear that this issue is not filed because `restrict` is broken. A `restrict title matches` block with 999 entries would work today, at zero context cost, which is more than I initially credited it with. ## What it does not do, in descending order of how much it matters **1. It is a glob matcher being asked to test membership.** `Restriction` is documented at `guardfile.go:100` as taking globs, and the parse error at `guardfile.go:797` says so. Every one of those 999 entries would be compiled and evaluated as a pattern. I checked whether this actually bites for this list, and **it does not: zero of the 999 titles contain `*`, `?`, `[`, `]`, or a backslash.** Twenty contain parentheses, which are not glob metacharacters. So for the motivating case this is a correctness non-issue and I am not going to pretend otherwise. The argument is about the general case and about direction of failure. **A metacharacter appearing in a literal makes a glob match more than the author wrote, which widens an allowlist.** In a framework whose entire discipline is failing closed, the one construct that silently fails open on a typo is worth replacing with an exact-set test. It is also the kind of bug that is invisible in review, because the guardfile line looks exactly like the 998 correct ones next to it. **2. It is not nameable, so it cannot be shared or stated.** `restrict` binds a list to one `{param}` name. A wrap with two grants taking the same collection under different path-parameter names needs two copies of 999 lines, and nothing in the file says they are meant to be the same set. A named set is declared once and referenced. **3. Nothing can say what the set is.** There is no way for `describe` output, `--help`, or a tool schema to carry "restricted to the 999 Wikipedia vital articles". The choice today is silence or the whole list. For an MCP consumer that is the difference between an agent that knows it holds a bounded collection and one that discovers the boundary by being refused 999 times. **4. There is nowhere to hang schema emission.** Confirmed by sweep: **umbra has no enum or schema-value-set concept anywhere.** The only two occurrences of "enum" in `docs/` are the English word "enumerate", in `specverb-policy.md:27` and `specverb-resolution.md:25`. So there is currently no answer to "which values may this field take" beyond type and bounds. ## Rough shape Naming to be settled in review. The reference syntax below is the part I am least sure about. ```kdl options vital_articles { describe "The 999 articles on Wikipedia's Vital Articles Level 3 list." member "Earth" member "Human" // ... 997 more } wrap ward mcp encyclopedia { base-url "en.wikipedia.org" auth none restrict title in vital_articles can get article_summary { path "/api/rest_v1/page/summary/{title}" } } ``` Properties worth having, whatever the spelling: * **Exact membership.** A set, not a pattern list. Hashable, order-independent, O(1) at call time rather than 999 glob evaluations. * **Fail-closed parse.** Empty set, duplicate member, empty member string, unknown child, a `restrict ... in` naming an undeclared set, and a declared set nothing references are all build errors. The last one matters: a 999-line block nobody uses is exactly the kind of thing that rots silently. * **Composes with the existing form.** `restrict <param> matches "<glob>"` keeps working untouched. This adds a second, stricter mode rather than reinterpreting the first, so no existing guardfile changes meaning. ## The thing this does NOT solve, stated plainly **Discovery.** A consumer still has no cheap way to learn what is in the set. The obvious move is emitting the members as JSON Schema `enum` on the field. **For a set this size that is the wrong default and probably the wrong option entirely.** 14,133 bytes in a tool schema is paid on every turn carrying the roster, against measurements in `coilyco-gaming/sirens-echo#932` of a 116 KB fixed prefix and `#1002` of that prefix being paid roughly six times per reply. Emitting this set would be a measurable regression on a lane already being worked for exactly that. So if schema emission is built at all it should be opt-in and default off, and **the `describe` string is the real discovery mechanism**: one sentence naming what the collection is, at roughly 60 bytes rather than 14,133. That is enough for an agent to describe its own boundary honestly, which is the capability-honesty requirement in `coilyco-gaming/sirens-echo#200`, and it is not enough for the agent to enumerate the shelf. Enumerating the shelf is a consumer-side problem and does not belong in this construct. I would rather this issue land as enforcement plus a description and be honest that discovery is unsolved, than grow a schema-emission feature whose default costs every consumer context they did not ask for. ## Open questions I am not deciding * **Wrap-level or top-level.** `applyNode` at `inline.go:75` owns the wrap body and its frozen set is `base-url | auth | header | restrict | can | proxy`. A top-level `options` sibling to `wrap` reads better, but `inherit` resolves **textually by splicing the wrap body** per `specverb-policy.md:23`, so a top-level node may not survive inheritance. That interaction should decide the placement rather than aesthetics. * **How a grant references a set.** `restrict <param> in <name>` reuses the existing enforcement path. `field "title" options=<name>` would extend it to typed query fields, which `restrict` cannot reach at all today since it is bound to `{param}` path templates. The second is more useful and a larger change. * **Whether `describe` auto-reports the count.** "999 members" is derivable and useful, and it is also a number that goes stale in prose the moment someone adds a member. * **A file-sourced form.** Deliberately excluded per Kai's call above. Recorded here only so a future reader knows it was considered and rejected rather than missed. ## Consumer waiting on it `coilyco-bridge/deploy#720` carries an epic that wants exactly this shape, and one child of it is an encyclopedia server whose whole design turns on a bounded curated collection. That work is not blocked, since `restrict ... matches` would carry it, but it would be authored twice if this lands afterward. ## Complete when * A named literal set can be declared in a guardfile and referenced by an enforcing construct. * Membership is exact-string, with no pattern interpretation of any member. * The set carries a description reaching `describe` and `--help`. * Parsing fails closed on every case listed above, including an unreferenced set. * Existing `restrict ... matches` guardfiles are byte-for-byte unaffected. * Schema emission is either absent or opt-in and off by default, with the context cost recorded in the docs rather than discovered by a consumer. Refs: #306 for the parallel frozen-grammar extension, `coilyco-bridge/deploy#720`.
Sign in to join this conversation.
No milestone
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/umbra#307
No description provided.