DLV-I12: the judge refuses a ledger type whose operations or indexes name what its schema lacks

Left by the bus-state review (ADR 0595 §8): a set-entry or remove names its into and key, an operation's fields and an
index's on name schema fields, and a digest-of names a text field; each refusal names module, entry, type, operation
or index, and the field.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-04 13:59:37 +01:00
parent 7372447c68
commit 87ba69ed69
2 changed files with 45 additions and 1 deletions
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"maps"
"slices"
"strings"
@@ -347,7 +348,9 @@ func workQueueProblems(at string, e catalogue.BusStateEntry) []string {
return nil
}
// ledgerTypeProblems judges a ledger's types: named locally and once, and every operation's effect one of the four.
// ledgerTypeProblems judges a ledger's types: named locally and once, and every operation's effect one of the four; a
// set-entry or remove names its `into` and `key`; an operation's `fields` and an index's `on` name fields of the type's
// schema; a `digest-of` names a text field of it (DLV-I12, ADR 0595 §8, ADR 0597 §2, §4).
func ledgerTypeProblems(at string, e catalogue.BusStateEntry) []string {
var problems []string
if len(e.Types) == 0 {
@@ -374,6 +377,34 @@ func ledgerTypeProblems(at string, e catalogue.BusStateEntry) []string {
"%s: the operation %q has the effect %q, outside the four (%s) (novox/hq ADR 0597 §2)",
where, op.Name, op.Effect, strings.Join(ledgerEffects, ", ")))
}
if op.Effect == catalogue.EffectSetEntry || op.Effect == catalogue.EffectRemove {
for field, v := range map[string]string{"into": op.Into, "key": op.Key} {
if strings.TrimSpace(v) == "" {
problems = append(problems, fmt.Sprintf("%s: the operation %q (%s) names no %s (novox/hq ADR 0597 §2)", where, op.Name, op.Effect, field))
}
}
}
for _, f := range op.Fields {
if _, ok := t.Schema[f]; !ok {
problems = append(problems, fmt.Sprintf("%s: the operation %q names the field %q, which the type's schema does not hold", where, op.Name, f))
}
}
}
for _, ix := range t.Indexes {
for _, f := range ix.On {
if _, ok := t.Schema[f]; !ok {
problems = append(problems, fmt.Sprintf("%s: the index %q is on the field %q, which the type's schema does not hold (novox/hq ADR 0597 §4)", where, ix.Name, f))
}
}
}
for _, name := range slices.Sorted(maps.Keys(t.Schema)) {
of := t.Schema[name].DigestOf
if of == "" {
continue
}
if src, ok := t.Schema[of]; !ok || src.Type != "text" {
problems = append(problems, fmt.Sprintf("%s: the field %q is the digest of %q, which is no text field of the type's schema", where, name, of))
}
}
}
return problems
@@ -52,6 +52,19 @@ func TestBusStateIsJudgedOnce(t *testing.T) {
{"a bare stream with a wrong when full", manifest(`{"name":"x","storage":"stream","configuration":{"when-full":"crash","max-mib":10,"max-age-days":9}}`), []string{"\"x\"", "\"when-full\"", "\"refuse\" or \"drop-oldest\""}},
{"a ledger effect outside the four", manifest(strings.Replace(ledger, `"set-fields"`, `"upsert"`, 1)), []string{"\"records\"", "effect \"upsert\"", "outside the four"}},
{"a ledger whose history expires", manifest(strings.Replace(ledger, `"options"`, `"configuration":{"max-age-days":30},"options"`, 1)), nil},
// DLV-I12 (ADR 0595 §8, ADR 0597 §2, §4): each refusal names the module, the entry, the type, the operation or index, and the field.
{"a set-entry naming no into", manifest(strings.Replace(ledger, `"effect":"set-fields","fields":["title"]`, `"effect":"set-entry","key":"title"`, 1)),
[]string{"\"records\"", "type \"decision\"", "operation \"propose\"", "no into"}},
{"a remove naming no key", manifest(strings.Replace(ledger, `"effect":"set-fields","fields":["title"]`, `"effect":"remove","into":"links"`, 1)),
[]string{"\"records\"", "operation \"propose\"", "no key"}},
{"a set-entry naming both", manifest(strings.Replace(ledger, `"effect":"set-fields","fields":["title"]`, `"effect":"set-entry","into":"links","key":"title","fields":["title"]`, 1)), nil},
{"an operation's field the schema lacks", manifest(strings.Replace(ledger, `"fields":["title"]`, `"fields":["title","body"]`, 1)),
[]string{"\"records\"", "operation \"propose\"", "field \"body\"", "schema does not hold"}},
{"an index on a field the schema lacks", manifest(strings.Replace(ledger, `"fields":["title"]}]`, `"fields":["title"]}],"indexes":[{"name":"by-owner","on":["owner"]}]`, 1)),
[]string{"\"records\"", "index \"by-owner\"", "field \"owner\""}},
{"a digest of a field that is no text", manifest(strings.Replace(ledger, `{"title":{"type":"text"}}`, `{"title":{"type":"text"},"n":{"type":"integer"},"sum":{"type":"text","digest-of":"n"}}`, 1)),
[]string{"\"records\"", "field \"sum\"", "digest of \"n\"", "no text field"}},
{"a digest of a text field", manifest(strings.Replace(ledger, `{"title":{"type":"text"}}`, `{"title":{"type":"text"},"sum":{"type":"text","digest-of":"title"}}`, 1)), nil},
} {
_, problems := Manifest([]byte(c.raw), nil)
switch {