Merge pull request 'The second reader release: the reader reads G's new words (design 47 G §621, §697)' (#188) from fix/47-G-reader-release-2 into main
This commit was merged in pull request #188.
This commit is contained in:
@@ -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
|
||||
@@ -1246,6 +1251,16 @@ type Artifact struct {
|
||||
// release keeps a manifest that names them whole (design 47 G §340, §409); the manifest validator checks their form.
|
||||
Needs []string `json:"needs,omitempty"`
|
||||
|
||||
// Calls are the capability words (design 47 G §488, §605) a package's code calls of the tool runner: the delivery plan
|
||||
// derives, for each module whose build resolved a version of such a package, an ordering edge to the module whose
|
||||
// artifact States the word. Read here so the second reader release keeps a manifest that names them (§621).
|
||||
Calls []string `json:"calls,omitempty"`
|
||||
|
||||
// States are the capability words (design 47 G §488) the tool runner's artifact states, the edge of a package's Calls
|
||||
// running to the module that states each (§545). Read here so the second reader release keeps a manifest that names
|
||||
// them (§621).
|
||||
States []string `json:"states,omitempty"`
|
||||
|
||||
// GeneratedFrom marks a package of a contracts module (novox/hq ADR 0608 §5, design 26 *Contracts modules*): the
|
||||
// directory, relative to the module's, of the seat definitions every file of the package is generated from. A
|
||||
// module carrying it holds contracts only and claims no seat, which the judge refuses naming the seat; the build
|
||||
@@ -1572,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" {
|
||||
@@ -1726,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
|
||||
}
|
||||
@@ -1744,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 {
|
||||
@@ -1797,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).
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
package catalogue
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/seatdef"
|
||||
)
|
||||
|
||||
// A manifest carrying the words design 47 G writes is read whole by the reader release (design 47 G §340, §409):
|
||||
// `version` as MAJOR.MINOR (§368), a `*` emitter in `consumes` (§386), an observer's finding stream (§387) and an
|
||||
@@ -21,3 +28,74 @@ func TestTheReaderReleaseReadsTheManifestWordsGWrites(t *testing.T) {
|
||||
t.Fatalf("read as %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// The second reader release reads the words G's manifests write beyond the first's (design 47 G §494, §621): each row a
|
||||
// manifest read whole, or refused naming what is wrong, and what the reader gives of it.
|
||||
func TestTheSecondReaderReleaseReadsGsManifestWords(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name, raw, refused string
|
||||
check func(Manifest) bool
|
||||
}{
|
||||
{name: "a state's cap (§469)", raw: `{"module":"m","version":"1.0","state":["plain",{"name":"pipelines","max-mib":256}]}`,
|
||||
check: func(m Manifest) bool { return m.State[0].MaxMiB == 0 && m.State[1].MaxMiB == 256 }},
|
||||
{name: "a state's cap out of the objects section's bounds (§469)",
|
||||
raw: `{"module":"m","version":"1.0","state":[{"name":"pipelines","max-mib":0}]}`, refused: "max-mib is between 1 and 8192"},
|
||||
{name: "a package's calls and the tool runner's states (§605, §545)",
|
||||
raw: `{"module":"m","version":"1.0","build":{"artifacts":[{"name":"go","kind":"package","language":"go","calls":["put-sessions"]},` +
|
||||
`{"name":"runtime","kind":"bundle","language":"go","states":["put-sessions","finding-calls"]}]}}`,
|
||||
check: func(m Manifest) bool {
|
||||
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 {
|
||||
case c.refused != "" && (err == nil || !strings.Contains(err.Error(), c.refused)):
|
||||
t.Errorf("%s: read as %v, want refused naming %q", c.name, err, c.refused)
|
||||
case c.refused == "" && err != nil:
|
||||
t.Errorf("%s: refused: %v", c.name, err)
|
||||
case c.refused == "" && (len(m.UnknownField()) != 0 || !c.check(m)):
|
||||
t.Errorf("%s: read as %+v (set aside %v)", c.name, m, m.UnknownField())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A consumed view of a seat, `seat:<seat>.views.<state>`, is read apart from a consumed event, and a seat's compiled form
|
||||
// names the states its definition offers as views (design 47 G §616, §699, §621).
|
||||
func TestTheSecondReaderReleaseReadsASeatNamedView(t *testing.T) {
|
||||
for _, row := range []struct {
|
||||
consume, seat, state string
|
||||
ok, bad bool
|
||||
}{
|
||||
{"seat:mesh-module-assigner.views.assignments", "mesh-module-assigner", "assignments", true, false},
|
||||
{"mesh-delivery-assigner.views.assignments", "", "", false, false},
|
||||
{"seat:mesh-module-assigner.assignments", "", "", true, true},
|
||||
{"seat:.views.assignments", "", "", true, true},
|
||||
} {
|
||||
seat, state, ok, err := SeatViewConsume(row.consume)
|
||||
if seat != row.seat || state != row.state || ok != row.ok || (err != nil) != row.bad {
|
||||
t.Errorf("SeatViewConsume(%q) = %q, %q, %v, %v", row.consume, seat, state, ok, err)
|
||||
}
|
||||
}
|
||||
m, err := decodeForTest([]byte(`{"module":"m","version":"1.0","consumes":["seat:mesh-module-assigner.views.assignments"]}`))
|
||||
if err != nil || len(m.UnknownField()) != 0 || m.Consumes[0] != "seat:mesh-module-assigner.views.assignments" {
|
||||
t.Errorf("a seat-named view in consumes read as %v, %v", m.Consumes, err)
|
||||
}
|
||||
s := SeatFromDefinition(seatdef.Definition{Seat: "mesh-module-assigner", Scope: "mesh",
|
||||
States: []seatdef.State{{Name: "assignments", View: true}, {Name: "placement"}}})
|
||||
if !slices.Equal(s.Views, []string{"assignments"}) {
|
||||
t.Errorf("the seat's views = %v", s.Views)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,11 @@ func SeatFromDefinition(d seatdef.Definition) Seat {
|
||||
Input: seatdef.ResolveRefs(v.Input, d.Defs), Output: seatdef.ResolveRefs(v.Answer, d.Defs),
|
||||
Optional: v.Optional, Replaces: v.Replaces})
|
||||
}
|
||||
for _, st := range d.States {
|
||||
if st.View {
|
||||
s.Views = append(s.Views, st.Name)
|
||||
}
|
||||
}
|
||||
for _, r := range d.Receives {
|
||||
rv := Receivable{Kind: r.Kind, Comment: r.Comment, Dirs: r.Dirs, Offered: r.Offered}
|
||||
if r.Shape != nil {
|
||||
|
||||
@@ -45,6 +45,9 @@ type Seat struct {
|
||||
// Serves carries each verb in full — name, description, schema — because a role's tools are the
|
||||
// mesh's to define and an agent's to call (novox/hq ADR 0132, design 33 §2).
|
||||
Serves []Verb
|
||||
// Views are the names of the states the seat offers as views, which a module follows by `consumes`
|
||||
// `seat:<seat>.views.<state>` (design 47 G §616, §699). Compiled from the definition's states marked `view`.
|
||||
Views []string
|
||||
// Receives is what other modules may contribute to the seat's holder, by kind (novox/hq ADR
|
||||
// 0212): each kind is text in the tool's own grammar, placed by the holder with
|
||||
// ${contribution:<seat>:<kind>}. Compiled, never stored: like the protocol, it is the mesh's
|
||||
|
||||
+32
-3
@@ -35,11 +35,14 @@ type StateDeclaration struct {
|
||||
// the tool runner's account, read it and never write it (the lead's ruling, 2026-11-04: nats' warrant spend record
|
||||
// `spent`). The judge's rule and the grants follow from it.
|
||||
ReadOnly bool `json:"read-only,omitempty"`
|
||||
// MaxMiB is the bucket's cap in MiB, between ObjectsLeastMiB and ObjectsMostMiB, as an old objects section's
|
||||
// is (design 47 G §469, §494); zero is no cap, as before.
|
||||
MaxMiB int `json:"max-mib,omitempty"`
|
||||
// KeptLayout is the layout its holder says it writes: its form and its catch-up tool (design 47).
|
||||
KeptLayout
|
||||
}
|
||||
|
||||
// UnmarshalJSON reads a bucket as its bare name, or as {name, history, ttl-seconds, per-machine, key-ttl, read-only, form, catch-up}.
|
||||
// UnmarshalJSON reads a bucket as its bare name, or as {name, history, ttl-seconds, per-machine, key-ttl, read-only, max-mib, form, catch-up}.
|
||||
func (s *StateDeclaration) UnmarshalJSON(raw []byte) error {
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if len(trimmed) > 0 && trimmed[0] == '"' {
|
||||
@@ -47,27 +50,53 @@ func (s *StateDeclaration) UnmarshalJSON(raw []byte) error {
|
||||
}
|
||||
type plain StateDeclaration
|
||||
var full plain
|
||||
var said struct {
|
||||
MaxMiB *int `json:"max-mib"`
|
||||
}
|
||||
_ = json.Unmarshal(trimmed, &said)
|
||||
dec := json.NewDecoder(bytes.NewReader(trimmed))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&full); err != nil {
|
||||
return fmt.Errorf("a state is either a name or {name, history, ttl-seconds, per-machine, key-ttl, read-only, form, catch-up}: %w", typedUnknown(err))
|
||||
return fmt.Errorf("a state is either a name or {name, history, ttl-seconds, per-machine, key-ttl, read-only, max-mib, form, catch-up}: %w", typedUnknown(err))
|
||||
}
|
||||
if err := full.KeptLayout.refuse(fmt.Sprintf("state %q", full.Name)); err != nil {
|
||||
return err
|
||||
}
|
||||
if said.MaxMiB != nil && (*said.MaxMiB < ObjectsLeastMiB || *said.MaxMiB > ObjectsMostMiB) {
|
||||
return fmt.Errorf("state %q: max-mib is between %d and %d, not %d", full.Name, ObjectsLeastMiB, ObjectsMostMiB, *said.MaxMiB)
|
||||
}
|
||||
*s = StateDeclaration(full)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON writes back the short form when there is nothing else to say.
|
||||
func (s StateDeclaration) MarshalJSON() ([]byte, error) {
|
||||
if s.History == 0 && s.TTLSeconds == 0 && !s.PerMachine && !s.KeyTTL && !s.ReadOnly && s.KeptLayout == (KeptLayout{}) {
|
||||
if s.History == 0 && s.TTLSeconds == 0 && !s.PerMachine && !s.KeyTTL && !s.ReadOnly && s.MaxMiB == 0 && s.KeptLayout == (KeptLayout{}) {
|
||||
return json.Marshal(s.Name)
|
||||
}
|
||||
type plain StateDeclaration
|
||||
return json.Marshal(plain(s))
|
||||
}
|
||||
|
||||
// SeatViewPrefix opens a consumed view of a seat, `seat:<seat>.views.<state>` (design 47 G §616).
|
||||
const SeatViewPrefix = "seat:"
|
||||
|
||||
// SeatViewConsume splits a consumed view of a seat, `seat:<seat>.views.<state>`, into the seat and the state; ok is false for a
|
||||
// consumed entry that is not of this kind (an event, `<emitter>.<event>`), and err says why a `seat:` entry is malformed. The
|
||||
// holder is not in it: it is resolved where the view is followed, so a new holder is followed with no change of the
|
||||
// manifest (design 47 G §616, §621).
|
||||
func SeatViewConsume(consume string) (seat, state string, ok bool, err error) {
|
||||
rest, isSeat := strings.CutPrefix(consume, SeatViewPrefix)
|
||||
if !isSeat {
|
||||
return "", "", false, nil
|
||||
}
|
||||
seat, view, named := strings.Cut(rest, ".views.")
|
||||
if !named || !stateName.MatchString(seat) || !stateName.MatchString(view) {
|
||||
return "", "", true, fmt.Errorf("%q does not name a seat and its view: a consumed view of a seat is seat:<seat>.views.<state>", consume)
|
||||
}
|
||||
return seat, view, true, nil
|
||||
}
|
||||
|
||||
// ReadState splits a read into the owning module and the local name, or says why it is not one.
|
||||
func ReadState(read string) (module, local string, err error) {
|
||||
at := strings.LastIndex(read, ".")
|
||||
|
||||
@@ -221,6 +221,12 @@ type Verb struct {
|
||||
// NeedsApproval marks the verb for every holder: the operator approves a call of it first (design 46 *The approval
|
||||
// mark* §2).
|
||||
NeedsApproval bool `json:"needs-approval,omitempty"`
|
||||
// ByCaller says the holder's process answers the verb itself and tells callers apart by the account the bus proved
|
||||
// (design 47 G §554, §621, §697).
|
||||
ByCaller bool `json:"by-caller,omitempty"`
|
||||
// Chunks says the holder's process sends the answer's file in chunks, each acknowledged by the caller, on the
|
||||
// subjects mesh.seat.<seat>.chunk.<verb>.<call>.<n> and mesh.seat.<seat>.ack.<verb>.<call>.<n> (design 47 G §681).
|
||||
Chunks bool `json:"chunks,omitempty"`
|
||||
}
|
||||
|
||||
// The streams an accepted kind may ride (design 47 G §415).
|
||||
@@ -402,6 +408,8 @@ type State struct {
|
||||
History int `json:"history,omitempty"`
|
||||
MaxAgeDays int `json:"max-age-days,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
// View is true when the seat offers the state as a view for a module to follow by `consumes` (design 47 G §699).
|
||||
View bool `json:"view,omitempty"`
|
||||
}
|
||||
|
||||
// KeyForm is one form a state's key takes: a pattern of named parts (`<asker>.<id>`, a part between angle brackets,
|
||||
|
||||
@@ -84,6 +84,8 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) {
|
||||
`"domain":"Core","decision":"ADR 1","records":[{"name":"asks"}],`, 1), "records: a field the format does not know"},
|
||||
{"a state with a key form and a value", withState(whole, `{"name":"asks","template":"state","writer":"caller",
|
||||
"history":3,"key-forms":[{"key":"<asker>.<id>","value":{"type":"object"}}]}`), ""},
|
||||
{"a state offered as a view", withState(whole, `{"name":"asks","template":"state","view":true,
|
||||
"key-forms":[{"key":"<id>","value":{"type":"object"}}]}`), ""},
|
||||
{"a stream state with an age", withState(whole, `{"name":"placement","template":"events","max-age-days":7,
|
||||
"key-forms":[{"key":"changed.<node>","value":{"type":"object"}}]}`), ""},
|
||||
{"a state of an unknown template", withState(whole, `{"name":"asks","template":"bucket",
|
||||
@@ -142,6 +144,8 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) {
|
||||
{"an acting rule on another kind", strings.Replace(listGrain, `"verbs":`, `"acts":[{"step":"build","on":"kind:Image"}],"verbs":`, 1), "acts[0].on"},
|
||||
{"a verb marked for approval", strings.Replace(whole, `"name":"list",`, `"name":"list","needs-approval":true,`, 1), ""},
|
||||
{"a verb marked with a word that is no boolean", strings.Replace(whole, `"name":"list",`, `"name":"list","needs-approval":"yes",`, 1), "not a seat definition's JSON"},
|
||||
{"a verb answered by its caller's account, in chunks", strings.Replace(whole, `"name":"list",`, `"name":"list","by-caller":true,"chunks":true,`, 1), ""},
|
||||
{"a verb's by-caller that is no boolean", strings.Replace(whole, `"name":"list",`, `"name":"list","by-caller":"yes",`, 1), "not a seat definition's JSON"},
|
||||
{"a bodies section", strings.Replace(whole, `"family":`, `"bodies":{"Finding":{"type":"object"}},"family":`, 1), ""},
|
||||
{"one kind on two streams", strings.Replace(whole, `"events":`,
|
||||
`"accepts":[{"name":"step","body":{},"stream":"check"},{"name":"step","body":{},"stream":"delivery"}],"events":`, 1), ""},
|
||||
|
||||
Reference in New Issue
Block a user