RR2-4: the manifest reader reads the long form of an invokes entry

The tool runner's invokes hold * beside {verb, fields} entries; Invokes
keeps every verb, so none of its readers changes, and InvokeFields maps a
verb to the fields it names, written back in the long form.

Builds: design 47 G §621, §617

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-12 03:08:25 +01:00
parent 3747b5f78c
commit 98f22ffe72
2 changed files with 97 additions and 1 deletions
+83 -1
View File
@@ -587,6 +587,11 @@ type Manifest struct {
// person's account (design 25 §7) already had the same shape.
Invokes []string `json:"invokes,omitempty"`
// InvokeFields maps a verb of Invokes to the fields it names, read from an entry of `invokes` in its long form
// {verb, fields}, which may stand beside `*` (design 47 G §617): Invokes keeps the verb, so none of its readers
// changes. A long-form entry naming no field is a verb of Invokes alone.
InvokeFields map[string][]string `json:"-"`
// Waits are the event families a session may wait on through the mesh MCP server's `mesh_wait`, each
// `<emitter>.<event>` as `consumes` names one, a seat's or a module's (novox/hq ADR 0367 §2). Only the tool
// runner's module declares them, one by one and never by a wildcard. **A grant apart from `consumes`**: the
@@ -1582,6 +1587,10 @@ func (m *Manifest) UnmarshalJSON(raw []byte) error {
if err := consumesEntryProblem(keys["consumes"]); err != nil {
return err
}
invokeFields, err := readInvokes(keys)
if err != nil {
return err
}
plain := map[string]string{}
many := map[string]map[string]string{}
if secrets, ok := keys["secrets"]; ok && string(secrets) != "null" {
@@ -1736,6 +1745,7 @@ func (m *Manifest) UnmarshalJSON(raw []byte) error {
m.forms = forms
m.OptionalRequires = optionalRequires
m.RequireShares = requireShares
m.InvokeFields = invokeFields
if len(plain) > 0 {
m.Secrets = plain
}
@@ -1754,6 +1764,61 @@ func (m *Manifest) UnmarshalJSON(raw []byte) error {
return nil
}
// invokeLongForm is an entry of `invokes` in its long form: the verb, and the fields of its answer the caller relies on
// (design 47 G §617).
type invokeLongForm struct {
Verb string `json:"verb"`
Fields []string `json:"fields,omitempty"`
}
// readInvokes reads `invokes` whose entries are verbs or, in their long form, {verb, fields}: it puts the verbs back in
// keys as plain names and answers the fields each long-form entry names.
func readInvokes(keys map[string]json.RawMessage) (map[string][]string, error) {
raw, ok := keys["invokes"]
if !ok || string(raw) == "null" {
return nil, nil
}
var entries []json.RawMessage
if err := json.Unmarshal(raw, &entries); err != nil {
return nil, nil // not a list: the field's own decoder says so
}
var verbs []string
var fields map[string][]string
for i, e := range entries {
trimmed := bytes.TrimSpace(e)
if len(trimmed) == 0 || trimmed[0] != '{' {
var verb string
if err := json.Unmarshal(trimmed, &verb); err != nil {
return nil, fmt.Errorf("invokes[%d]: a verb, or {verb, fields}: %w", i, err)
}
verbs = append(verbs, verb)
continue
}
var long invokeLongForm
dec := json.NewDecoder(bytes.NewReader(trimmed))
dec.DisallowUnknownFields()
if err := dec.Decode(&long); err != nil {
return nil, fmt.Errorf("invokes[%d]: a verb, or {verb, fields}: %w", i, err)
}
if long.Verb == "" {
return nil, fmt.Errorf("invokes[%d]: a long-form entry names its verb", i)
}
verbs = append(verbs, long.Verb)
if len(long.Fields) > 0 {
if fields == nil {
fields = map[string][]string{}
}
fields[long.Verb] = long.Fields
}
}
plain, err := json.Marshal(verbs)
if err != nil {
return nil, err
}
keys["invokes"] = plain
return fields, nil
}
// checkSharesNotContributed refuses a module whose own `contributes` to s3-bucket names `shares` (ADR 0468 §8).
func (m Manifest) checkSharesNotContributed() error {
if len(m.RequireShares[BucketRequirement]) == 0 {
@@ -1807,13 +1872,30 @@ func (m Manifest) marshalFields() ([]byte, error) {
if err != nil {
return nil, err
}
if len(m.SecretsMany) == 0 && len(m.ContributesMany) == 0 && len(m.OptionalRequires) == 0 && len(m.RequireShares) == 0 {
if len(m.SecretsMany) == 0 && len(m.ContributesMany) == 0 && len(m.OptionalRequires) == 0 && len(m.RequireShares) == 0 &&
len(m.InvokeFields) == 0 {
return raw, nil
}
var keys map[string]json.RawMessage
if err := json.Unmarshal(raw, &keys); err != nil {
return nil, err
}
if len(m.InvokeFields) > 0 {
// Each verb that names fields is written back in its long form (design 47 G §617).
entries := make([]any, 0, len(m.Invokes))
for _, verb := range m.Invokes {
if fields, named := m.InvokeFields[verb]; named {
entries = append(entries, invokeLongForm{Verb: verb, Fields: fields})
continue
}
entries = append(entries, verb)
}
listed, err := json.Marshal(entries)
if err != nil {
return nil, err
}
keys["invokes"] = listed
}
if optional := optionalRequiresList(m); len(optional) > 0 {
// Beside `requires`, never inside it: an older controller reads `requires` as plain names, and one
// manifest it cannot read stops the whole catalogue (OptionalRequiresKey).
+14
View File
@@ -1,6 +1,8 @@
package catalogue
import (
"encoding/json"
"slices"
"strings"
"testing"
)
@@ -43,6 +45,18 @@ func TestTheSecondReaderReleaseReadsGsManifestWords(t *testing.T) {
a := m.Build.Artifacts
return len(a) == 2 && len(a[0].Calls) == 1 && a[0].Calls[0] == "put-sessions" && len(a[1].States) == 2
}},
{name: "* with two long-form invokes (§617)",
raw: `{"module":"m","version":"1.0","invokes":["*",{"verb":"seat:mesh-catalog.revision","fields":["none-yet"]},` +
`{"verb":"seat:mesh-catalog.entry"}]}`,
check: func(m Manifest) bool {
out, _ := json.Marshal(m)
return slices.Equal(m.Invokes, []string{"*", "seat:mesh-catalog.revision", "seat:mesh-catalog.entry"}) &&
len(m.InvokeFields) == 1 && slices.Equal(m.InvokeFields["seat:mesh-catalog.revision"], []string{"none-yet"}) &&
strings.Contains(string(out), `"invokes":["*",{"verb":"seat:mesh-catalog.revision","fields":["none-yet"]},"seat:mesh-catalog.entry"]`)
}},
{name: "a long-form invoke with a word it does not know (§617)",
raw: `{"module":"m","version":"1.0","invokes":[{"verb":"seat:mesh-catalog.revision","field":["none-yet"]}]}`,
refused: `invokes[0]: a verb, or {verb, fields}`},
} {
m, err := decodeForTest([]byte(c.raw))
switch {