diff --git a/go/catalogue/manifest.go b/go/catalogue/manifest.go index ec6f765..ef1f49c 100644 --- a/go/catalogue/manifest.go +++ b/go/catalogue/manifest.go @@ -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 // `.` 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). diff --git a/go/catalogue/reader_release_test.go b/go/catalogue/reader_release_test.go index a52ba46..0a4f4e5 100644 --- a/go/catalogue/reader_release_test.go +++ b/go/catalogue/reader_release_test.go @@ -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 {