From 8bce07def3d6cbd7746295680e748bd28eb73e0a Mon Sep 17 00:00:00 2001
From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com>
Date: Mon, 7 Sep 2026 11:53:53 +0200
Subject: [PATCH 01/43] feat(frames): add the Frame template model and
requirement check
---
backend/internal/frames/templates.go | 258 ++++++++++++++++++
backend/internal/frames/templates_test.go | 303 ++++++++++++++++++++++
2 files changed, 561 insertions(+)
create mode 100644 backend/internal/frames/templates.go
create mode 100644 backend/internal/frames/templates_test.go
diff --git a/backend/internal/frames/templates.go b/backend/internal/frames/templates.go
new file mode 100644
index 0000000..858f2b3
--- /dev/null
+++ b/backend/internal/frames/templates.go
@@ -0,0 +1,258 @@
+package frames
+
+import (
+ "bytes"
+ "fmt"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+)
+
+// This file implements Frame templates: an authoring affordance local to this
+// implementation of the Frame Spec. A template is NOT a Frame and NOT a spec
+// concept. It is copied once when a Frame is created and has no further
+// relationship to that Frame: nothing is recorded on the Frame, and editing or
+// deleting a template cannot affect anything already made from it. The
+// reasoning, including why templates are not modelled as Frames, is in
+// docs/adr/0001-frame-templates-are-not-frames.md.
+
+// Requirement is how strongly a template wants a slot filled. It is stricter
+// than the Frame schema, which requires no slot at all: an org uses it to hold
+// its own Frames to a house standard the spec does not impose.
+type Requirement int
+
+const (
+ // RequirementOptional is the default for any slot a template omits.
+ RequirementOptional Requirement = iota
+ // RequirementRecommended is never enforced. It tells the authoring surfaces
+ // to offer the section, not to insist on it.
+ RequirementRecommended
+ // RequirementRequired refuses the create-from-template publish while the
+ // slot is empty.
+ RequirementRequired
+)
+
+func (r Requirement) String() string {
+ switch r {
+ case RequirementRecommended:
+ return "recommended"
+ case RequirementRequired:
+ return "required"
+ default:
+ return "optional"
+ }
+}
+
+// UnmarshalYAML accepts the level names the built-in template files use.
+// An unknown value is an error rather than a silent fall back to optional: a
+// typo in a level would otherwise quietly drop a requirement its author meant
+// to impose, and the whole point of the field is to be a control.
+func (r *Requirement) UnmarshalYAML(n *yaml.Node) error {
+ var s string
+ if err := n.Decode(&s); err != nil {
+ return err
+ }
+ switch strings.ToLower(strings.TrimSpace(s)) {
+ case "optional":
+ *r = RequirementOptional
+ case "recommended":
+ *r = RequirementRecommended
+ case "required":
+ *r = RequirementRequired
+ default:
+ return fmt.Errorf("unknown requirement level %q: want optional, recommended, or required", s)
+ }
+ return nil
+}
+
+// MarshalYAML writes the level by name, so a definition file round-trips as
+// something a person would want to edit.
+func (r Requirement) MarshalYAML() (any, error) { return r.String(), nil }
+
+// FieldRule is a template's expectation for one slot: how strongly the slot is
+// wanted, and what belongs in it. Note carries the template author's own
+// guidance, which is more use than the generic per-slot hints the surfaces
+// would otherwise show, and is the question an MCP client asks when it runs an
+// authoring interview (see issue #61).
+type FieldRule struct {
+ Level Requirement `yaml:"level"`
+ Note string `yaml:"note,omitempty"`
+}
+
+// Prefill is the content a template seeds into a new Frame. It is a strict
+// subset of Doc on purpose: a template seeds content and must never dictate a
+// Frame's identity, so name, description, and version are not representable.
+type Prefill struct {
+ Slots Slots
+ Extends []ExtendRef
+}
+
+// prefillExtendRef is a narrower view of ExtendRef used only for marshalling:
+// prefill encoding omits version to keep templates' extend specs minimal.
+// Decoding through Parse preserves version normally.
+type prefillExtendRef struct {
+ Ref string `yaml:"ref"`
+}
+
+// prefillDoc is the serialized shape of a Prefill. Decoding goes through Parse,
+// which yields a full Doc and so catches identity fields a template must not
+// set; encoding uses this narrower type so those keys are omitted entirely
+// rather than emitted empty. The asymmetry is deliberate: it means a blob this
+// package writes is always one it will accept back.
+type prefillDoc struct {
+ Extends []prefillExtendRef `yaml:"extends,omitempty"`
+ Slots Slots `yaml:"slots"`
+}
+
+// Template is one starter definition. Built-ins are compiled in; org templates
+// are rows in frame_templates. Nothing downstream distinguishes them beyond the
+// ID prefix, which only the RPC layer reads (to refuse mutations on a built-in).
+type Template struct {
+ ID string
+ Title string
+ Description string
+ Prefill Prefill
+ FieldRules map[string]FieldRule // slot key from SlotTable -> rule
+}
+
+// BuiltinPrefix marks the IDs of templates compiled into the binary. They have
+// no org and no row, so they cannot be edited or deleted.
+const BuiltinPrefix = "builtin:"
+
+// BlankTemplateID is the "start from nothing" option. It is a real template with
+// no prefill and no rules rather than a special case in the picker, which keeps
+// the seed path uniform: starting blank is not a branch.
+const BlankTemplateID = BuiltinPrefix + "blank"
+
+// IsBuiltin reports whether id names a template compiled into the binary.
+func IsBuiltin(id string) bool { return strings.HasPrefix(id, BuiltinPrefix) }
+
+// slotByKey resolves a slot key to its descriptor. SlotTable is ten entries and
+// this runs at load and validation time only, so a linear scan is the right
+// cost; SlotByHeading next door does the same.
+func slotByKey(key string) (SlotDescriptor, bool) {
+ for _, d := range SlotTable {
+ if d.Key == key {
+ return d, true
+ }
+ }
+ return SlotDescriptor{}, false
+}
+
+// slotHasContent reports whether a slot carries anything an author would call
+// content. Emptiness is defined here rather than trusting the frontend's
+// sectionHasContent: this is what the publish path checks against, and a client
+// computing it differently must not be able to slip a blank required section
+// past the check.
+func slotHasContent(d SlotDescriptor, slots *Slots) bool {
+ switch d.Kind {
+ case SlotTerms:
+ // A term with no definition is not content: it is the start of one.
+ for _, term := range slots.Terminology {
+ if strings.TrimSpace(term.Term) != "" && strings.TrimSpace(term.Definition) != "" {
+ return true
+ }
+ }
+ return false
+ case SlotList:
+ for _, item := range slots.List(d.Key) {
+ if strings.TrimSpace(item) != "" {
+ return true
+ }
+ }
+ return false
+ case SlotProse:
+ return strings.TrimSpace(slots.Prose(d.Key)) != ""
+ }
+ return false
+}
+
+// Check reports the slots this template requires that doc leaves empty.
+//
+// Pure and side-effect free: the publish path merges these with the schema's own
+// violations so an author sees every problem at once, instead of fixing the
+// schema's and only then discovering the template's.
+//
+// Only RequirementRequired produces a violation. Recommended is guidance for the
+// authoring surfaces and is deliberately not enforced, and this runs on create
+// only - see the ADR on why requirements are an authoring aid rather than
+// ongoing governance.
+func (t *Template) Check(doc *Doc) []FieldError {
+ if t == nil || len(t.FieldRules) == 0 {
+ return nil
+ }
+ var errs []FieldError
+ // Walk SlotTable rather than ranging the map, so violations come out in
+ // schema order: that matches how Validate reports and how the form lays its
+ // sections out, and map order would make the output nondeterministic.
+ for _, d := range SlotTable {
+ rule, ok := t.FieldRules[d.Key]
+ if !ok || rule.Level != RequirementRequired {
+ continue
+ }
+ if slotHasContent(d, &doc.Slots) {
+ continue
+ }
+ msg := fmt.Sprintf("required by the %q template", t.Title)
+ if rule.Note != "" {
+ msg += ": " + rule.Note
+ }
+ errs = append(errs, FieldError{Path: "slots." + d.Key, Message: msg})
+ }
+ return errs
+}
+
+// ParsePrefill decodes a prefill blob: the canonical YAML subset carrying slots
+// and suggested parents.
+//
+// It rejects every identity field. Rejecting beats ignoring: a template author
+// who set `name` meant something by it, and silently dropping it would surprise
+// them the first time a Frame came out with a different name. Excludes are
+// refused for the same reason - an exclusion is a statement about one Frame's
+// relationship to another, not something a starter can decide in advance.
+func ParsePrefill(content []byte) (Prefill, error) {
+ doc, err := Parse(content)
+ if err != nil {
+ return Prefill{}, err
+ }
+ for _, f := range []struct {
+ field string
+ set bool
+ }{
+ {"name", doc.Name != ""},
+ {"description", doc.Description != ""},
+ {"version", doc.Version != ""},
+ {"visibility", doc.Visibility != ""},
+ {"scope", doc.Scope != ""},
+ {"maintainer", doc.Maintainer != ""},
+ } {
+ if f.set {
+ return Prefill{}, fmt.Errorf(
+ "prefill must not set %s: a template seeds content, not identity", f.field)
+ }
+ }
+ if len(doc.Excludes) > 0 {
+ return Prefill{}, fmt.Errorf(
+ "prefill must not set excludes: an exclusion belongs to a Frame, not to the template it started from")
+ }
+ return Prefill{Slots: doc.Slots, Extends: doc.Extends}, nil
+}
+
+// MarshalPrefill encodes a Prefill to the canonical YAML subset the API carries
+// and the CLI scaffold writes.
+func MarshalPrefill(p Prefill) ([]byte, error) {
+ extends := make([]prefillExtendRef, len(p.Extends))
+ for i, e := range p.Extends {
+ extends[i] = prefillExtendRef{Ref: e.Ref}
+ }
+ return yaml.Marshal(prefillDoc{Extends: extends, Slots: p.Slots})
+}
+
+// unmarshalStrict decodes YAML into v, rejecting unknown keys. Template
+// definition files are ours and their key set is fixed, so a typo must fail
+// loudly rather than leave a rule silently unset.
+func unmarshalStrict(b []byte, v any) error {
+ dec := yaml.NewDecoder(bytes.NewReader(b))
+ dec.KnownFields(true)
+ return dec.Decode(v)
+}
diff --git a/backend/internal/frames/templates_test.go b/backend/internal/frames/templates_test.go
new file mode 100644
index 0000000..05d646a
--- /dev/null
+++ b/backend/internal/frames/templates_test.go
@@ -0,0 +1,303 @@
+package frames
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestRequirementUnmarshalYAML(t *testing.T) {
+ tests := []struct {
+ name string
+ yaml string
+ want Requirement
+ wantErr string
+ }{
+ {name: "optional", yaml: "level: optional", want: RequirementOptional},
+ {name: "recommended", yaml: "level: recommended", want: RequirementRecommended},
+ {name: "required", yaml: "level: required", want: RequirementRequired},
+ {name: "case and space insensitive", yaml: "level: \" Required \"", want: RequirementRequired},
+ {name: "unknown level is an error", yaml: "level: mandatory", wantErr: "unknown requirement level"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var got FieldRule
+ err := unmarshalStrict([]byte(tt.yaml), &got)
+ if tt.wantErr != "" {
+ if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
+ t.Fatalf("err = %v, want containing %q", err, tt.wantErr)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got.Level != tt.want {
+ t.Errorf("level = %v, want %v", got.Level, tt.want)
+ }
+ })
+ }
+}
+
+func TestTemplateCheck(t *testing.T) {
+ tests := []struct {
+ name string
+ title string
+ rules map[string]FieldRule
+ slots Slots
+ wantPaths []string
+ wantMsg string
+ }{
+ {
+ name: "required prose slot empty is a violation",
+ rules: map[string]FieldRule{"style": {Level: RequirementRequired}},
+ slots: Slots{},
+ wantPaths: []string{"slots.style"},
+ },
+ {
+ name: "required prose slot of only whitespace is empty",
+ rules: map[string]FieldRule{"style": {Level: RequirementRequired}},
+ slots: Slots{Style: " \n\t "},
+ wantPaths: []string{"slots.style"},
+ },
+ {
+ name: "required prose slot filled passes",
+ rules: map[string]FieldRule{"style": {Level: RequirementRequired}},
+ slots: Slots{Style: "Plain, direct sentences."},
+ wantPaths: nil,
+ },
+ {
+ name: "required list slot of only blank entries is empty",
+ rules: map[string]FieldRule{"rules": {Level: RequirementRequired}},
+ slots: Slots{Rules: []string{"", " "}},
+ wantPaths: []string{"slots.rules"},
+ },
+ {
+ name: "required list slot with one real entry passes",
+ rules: map[string]FieldRule{"rules": {Level: RequirementRequired}},
+ slots: Slots{Rules: []string{"", "Never ship on a Friday."}},
+ wantPaths: nil,
+ },
+ {
+ name: "required terms slot needs a definition too",
+ rules: map[string]FieldRule{"terminology": {Level: RequirementRequired}},
+ slots: Slots{Terminology: []Term{{Term: "Frame", Definition: " "}}},
+ wantPaths: []string{"slots.terminology"},
+ },
+ {
+ name: "required terms slot with a complete entry passes",
+ rules: map[string]FieldRule{"terminology": {Level: RequirementRequired}},
+ slots: Slots{Terminology: []Term{{Term: "Frame", Definition: "A scoped context artifact."}}},
+ wantPaths: nil,
+ },
+ {
+ name: "recommended is never enforced",
+ rules: map[string]FieldRule{"style": {Level: RequirementRecommended}},
+ slots: Slots{},
+ wantPaths: nil,
+ },
+ {
+ name: "optional is never enforced",
+ rules: map[string]FieldRule{"style": {Level: RequirementOptional}},
+ slots: Slots{},
+ wantPaths: nil,
+ },
+ {
+ name: "a slot with no rule is never enforced",
+ rules: map[string]FieldRule{},
+ slots: Slots{},
+ wantPaths: nil,
+ },
+ {
+ name: "violations come out in SlotTable order, not map order",
+ rules: map[string]FieldRule{
+ "business_process": {Level: RequirementRequired},
+ "terminology": {Level: RequirementRequired},
+ "style": {Level: RequirementRequired},
+ },
+ slots: Slots{},
+ wantPaths: []string{"slots.terminology", "slots.style", "slots.business_process"},
+ },
+ {
+ name: "the rule's note is carried into the message",
+ title: "Domain Vocabulary",
+ rules: map[string]FieldRule{"terminology": {Level: RequirementRequired, Note: "One entry per term of art."}},
+ slots: Slots{},
+ wantPaths: []string{"slots.terminology"},
+ wantMsg: `required by the "Domain Vocabulary" template: One entry per term of art.`,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tmpl := &Template{ID: "builtin:test", Title: tt.title, FieldRules: tt.rules}
+ got := tmpl.Check(&Doc{Slots: tt.slots})
+ if len(got) != len(tt.wantPaths) {
+ t.Fatalf("got %d violations %v, want %d %v", len(got), got, len(tt.wantPaths), tt.wantPaths)
+ }
+ for i, want := range tt.wantPaths {
+ if got[i].Path != want {
+ t.Errorf("violation %d path = %q, want %q", i, got[i].Path, want)
+ }
+ }
+ if tt.wantMsg != "" && got[0].Message != tt.wantMsg {
+ t.Errorf("message = %q, want %q", got[0].Message, tt.wantMsg)
+ }
+ })
+ }
+}
+
+func TestTemplateCheckNilTemplateIsNoOp(t *testing.T) {
+ var tmpl *Template
+ if got := tmpl.Check(&Doc{}); got != nil {
+ t.Errorf("nil template produced %v, want nil", got)
+ }
+}
+
+func TestParsePrefill(t *testing.T) {
+ tests := []struct {
+ name string
+ yaml string
+ wantErr string
+ wantTerm string
+ wantExt int
+ }{
+ {
+ name: "slots and extends are kept",
+ yaml: "extends:\n - ref: acme/base\n version: 1.0.0\nslots:\n terminology:\n - term: Frame\n definition: A scoped context artifact.\n",
+ wantTerm: "Frame",
+ wantExt: 1,
+ },
+ {name: "name is rejected", yaml: "name: brand-voice\nslots: {}\n", wantErr: "must not set name"},
+ {name: "version is rejected", yaml: "version: 1.0.0\nslots: {}\n", wantErr: "must not set version"},
+ {name: "description is rejected", yaml: "description: hello\nslots: {}\n", wantErr: "must not set description"},
+ {name: "visibility is rejected", yaml: "visibility: internal\nslots: {}\n", wantErr: "must not set visibility"},
+ {name: "scope is rejected", yaml: "scope: team\nslots: {}\n", wantErr: "must not set scope"},
+ {name: "maintainer is rejected", yaml: "maintainer: a@b.c\nslots: {}\n", wantErr: "must not set maintainer"},
+ {name: "excludes is rejected", yaml: "excludes:\n - acme/other\nslots: {}\n", wantErr: "must not set excludes"},
+ {name: "an unknown key is rejected", yaml: "colour: red\nslots: {}\n", wantErr: "parse frame yaml"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := ParsePrefill([]byte(tt.yaml))
+ if tt.wantErr != "" {
+ if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
+ t.Fatalf("err = %v, want containing %q", err, tt.wantErr)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(got.Extends) != tt.wantExt {
+ t.Errorf("extends = %d, want %d", len(got.Extends), tt.wantExt)
+ }
+ if tt.wantTerm != "" {
+ if len(got.Slots.Terminology) != 1 || got.Slots.Terminology[0].Term != tt.wantTerm {
+ t.Errorf("terminology = %v, want one entry %q", got.Slots.Terminology, tt.wantTerm)
+ }
+ }
+ })
+ }
+}
+
+func TestPrefillRoundTripsWithoutIdentityKeys(t *testing.T) {
+ in := Prefill{
+ Slots: Slots{Style: "Plain sentences.", Rules: []string{"No em dashes."}},
+ Extends: []ExtendRef{{Ref: "acme/base", Version: "1.0.0"}},
+ }
+ b, err := MarshalPrefill(in)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ // Identity keys must not be emitted at all: ParsePrefill rejects them, so a
+ // blob this function produced has to survive being read back.
+ for _, key := range []string{"name:", "version:", "description:", "visibility:"} {
+ if strings.Contains(string(b), key) {
+ t.Errorf("marshalled prefill contains %q:\n%s", key, b)
+ }
+ }
+ out, err := ParsePrefill(b)
+ if err != nil {
+ t.Fatalf("round trip rejected its own output: %v\n%s", err, b)
+ }
+ if out.Slots.Style != in.Slots.Style || len(out.Extends) != 1 {
+ t.Errorf("round trip lost content: %+v", out)
+ }
+}
+
+// TestSlotHasContentCoversEverySlot is the reflective guard the spec calls for,
+// mirroring the ones in backend/internal/mcp/resources_test.go. It walks
+// SlotTable so that adding a slot without teaching slotHasContent about its kind
+// fails here. Without it, a new slot kind would fall through the switch, always
+// read as empty, and make a `required` rule on it permanently unsatisfiable -
+// silently, from the author's point of view.
+func TestSlotHasContentCoversEverySlot(t *testing.T) {
+ for _, d := range SlotTable {
+ t.Run(d.Key, func(t *testing.T) {
+ // Empty must read as empty.
+ var empty Slots
+ if slotHasContent(d, &empty) {
+ t.Errorf("an empty %s slot reads as having content", d.Key)
+ }
+ // A populated instance of this slot's kind must read as content.
+ filled := &Slots{}
+ switch d.Kind {
+ case SlotTerms:
+ filled.Terminology = []Term{{Term: "Frame", Definition: "A scoped context artifact."}}
+ case SlotList:
+ filled.SetList(d.Key, []string{"An entry."})
+ case SlotProse:
+ filled.SetProse(d.Key, "Some prose.")
+ default:
+ t.Fatalf("slot %s has kind %v, which slotHasContent does not handle; add a case", d.Key, d.Kind)
+ }
+ if !slotHasContent(d, filled) {
+ t.Errorf("a populated %s slot reads as empty; slotHasContent is missing a case for kind %v", d.Key, d.Kind)
+ }
+ })
+ }
+}
+
+func TestIsBuiltin(t *testing.T) {
+ tests := []struct {
+ id string
+ want bool
+ }{
+ {id: "builtin:blank", want: true},
+ {id: "builtin:domain-vocabulary", want: true},
+ {id: "01J000000000000000000000AB", want: false},
+ {id: "", want: false},
+ {id: "notbuiltin:x", want: false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.id, func(t *testing.T) {
+ if got := IsBuiltin(tt.id); got != tt.want {
+ t.Errorf("IsBuiltin(%q) = %v, want %v", tt.id, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestSlotByKey(t *testing.T) {
+ tests := []struct {
+ key string
+ wantOK bool
+ wantKey string
+ }{
+ {key: "terminology", wantOK: true, wantKey: "terminology"},
+ {key: "style", wantOK: true, wantKey: "style"},
+ {key: "business_process", wantOK: true, wantKey: "business_process"},
+ {key: "unknown", wantOK: false},
+ {key: "", wantOK: false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.key, func(t *testing.T) {
+ got, ok := slotByKey(tt.key)
+ if ok != tt.wantOK {
+ t.Errorf("slotByKey(%q) ok = %v, want %v", tt.key, ok, tt.wantOK)
+ }
+ if ok && got.Key != tt.wantKey {
+ t.Errorf("slotByKey(%q) key = %q, want %q", tt.key, got.Key, tt.wantKey)
+ }
+ })
+ }
+}
From f0d98b2a20b52c1c9c4483925651fc2e36ee5dc0 Mon Sep 17 00:00:00 2001
From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com>
Date: Mon, 7 Sep 2026 12:01:40 +0200
Subject: [PATCH 02/43] fix: restore MarshalPrefill to preserve pinned parent
version
---
backend/internal/frames/templates.go | 17 +++--------------
backend/internal/frames/templates_test.go | 21 ++++++++++++++++-----
2 files changed, 19 insertions(+), 19 deletions(-)
diff --git a/backend/internal/frames/templates.go b/backend/internal/frames/templates.go
index 858f2b3..9a06c8d 100644
--- a/backend/internal/frames/templates.go
+++ b/backend/internal/frames/templates.go
@@ -87,21 +87,14 @@ type Prefill struct {
Extends []ExtendRef
}
-// prefillExtendRef is a narrower view of ExtendRef used only for marshalling:
-// prefill encoding omits version to keep templates' extend specs minimal.
-// Decoding through Parse preserves version normally.
-type prefillExtendRef struct {
- Ref string `yaml:"ref"`
-}
-
// prefillDoc is the serialized shape of a Prefill. Decoding goes through Parse,
// which yields a full Doc and so catches identity fields a template must not
// set; encoding uses this narrower type so those keys are omitted entirely
// rather than emitted empty. The asymmetry is deliberate: it means a blob this
// package writes is always one it will accept back.
type prefillDoc struct {
- Extends []prefillExtendRef `yaml:"extends,omitempty"`
- Slots Slots `yaml:"slots"`
+ Extends []ExtendRef `yaml:"extends,omitempty"`
+ Slots Slots `yaml:"slots"`
}
// Template is one starter definition. Built-ins are compiled in; org templates
@@ -241,11 +234,7 @@ func ParsePrefill(content []byte) (Prefill, error) {
// MarshalPrefill encodes a Prefill to the canonical YAML subset the API carries
// and the CLI scaffold writes.
func MarshalPrefill(p Prefill) ([]byte, error) {
- extends := make([]prefillExtendRef, len(p.Extends))
- for i, e := range p.Extends {
- extends[i] = prefillExtendRef{Ref: e.Ref}
- }
- return yaml.Marshal(prefillDoc{Extends: extends, Slots: p.Slots})
+ return yaml.Marshal(prefillDoc{Extends: p.Extends, Slots: p.Slots})
}
// unmarshalStrict decodes YAML into v, rejecting unknown keys. Template
diff --git a/backend/internal/frames/templates_test.go b/backend/internal/frames/templates_test.go
index 05d646a..a24df3f 100644
--- a/backend/internal/frames/templates_test.go
+++ b/backend/internal/frames/templates_test.go
@@ -1,6 +1,7 @@
package frames
import (
+ "regexp"
"strings"
"testing"
)
@@ -210,17 +211,27 @@ func TestPrefillRoundTripsWithoutIdentityKeys(t *testing.T) {
}
// Identity keys must not be emitted at all: ParsePrefill rejects them, so a
// blob this function produced has to survive being read back.
- for _, key := range []string{"name:", "version:", "description:", "visibility:"} {
- if strings.Contains(string(b), key) {
- t.Errorf("marshalled prefill contains %q:\n%s", key, b)
+ //
+ // Anchored to the start of a line. A bare substring check would also match
+ // the nested `extends[].version` key, which is legitimate and must be kept:
+ // that field is the pinned parent version, and Validate rejects an extends
+ // ref without one.
+ for _, key := range []string{"name:", "version:", "description:", "visibility:", "scope:", "maintainer:", "excludes:"} {
+ if regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(key)).Match(b) {
+ t.Errorf("marshalled prefill emits top-level %q:\n%s", key, b)
}
}
out, err := ParsePrefill(b)
if err != nil {
t.Fatalf("round trip rejected its own output: %v\n%s", err, b)
}
- if out.Slots.Style != in.Slots.Style || len(out.Extends) != 1 {
- t.Errorf("round trip lost content: %+v", out)
+ if out.Slots.Style != in.Slots.Style {
+ t.Errorf("round trip lost slot content: %+v", out.Slots)
+ }
+ // The pin specifically: dropping the version would leave every Frame seeded
+ // from a template with suggested parents unpublishable.
+ if len(out.Extends) != 1 || out.Extends[0].Ref != "acme/base" || out.Extends[0].Version != "1.0.0" {
+ t.Errorf("round trip lost the pinned parent: %+v", out.Extends)
}
}
From abcf6c82c9ea85deb249b145bb1291cdaaee514e Mon Sep 17 00:00:00 2001
From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com>
Date: Mon, 7 Sep 2026 12:07:08 +0200
Subject: [PATCH 03/43] feat(frames): ship built-in Frame templates, validated
at boot
---
backend/internal/frames/builtins.go | 130 ++++++++++++++++++
backend/internal/frames/builtins/blank.yaml | 5 +
.../internal/frames/builtins/brand-voice.yaml | 13 ++
.../frames/builtins/business-process.yaml | 13 ++
.../frames/builtins/domain-vocabulary.yaml | 10 ++
.../frames/builtins/engineering-norms.yaml | 13 ++
.../frames/builtins/product-context.yaml | 16 +++
backend/internal/frames/builtins_test.go | 106 ++++++++++++++
8 files changed, 306 insertions(+)
create mode 100644 backend/internal/frames/builtins.go
create mode 100644 backend/internal/frames/builtins/blank.yaml
create mode 100644 backend/internal/frames/builtins/brand-voice.yaml
create mode 100644 backend/internal/frames/builtins/business-process.yaml
create mode 100644 backend/internal/frames/builtins/domain-vocabulary.yaml
create mode 100644 backend/internal/frames/builtins/engineering-norms.yaml
create mode 100644 backend/internal/frames/builtins/product-context.yaml
create mode 100644 backend/internal/frames/builtins_test.go
diff --git a/backend/internal/frames/builtins.go b/backend/internal/frames/builtins.go
new file mode 100644
index 0000000..77e4741
--- /dev/null
+++ b/backend/internal/frames/builtins.go
@@ -0,0 +1,130 @@
+package frames
+
+import (
+ "embed"
+ "fmt"
+ "sort"
+ "strings"
+)
+
+// Built-in templates are compiled in rather than seeded into the database.
+// Seeding them per org would mean N rows per org and reconciliation on every
+// upgrade, and a shared "system org" is unreadable by design: rbac.Can opens
+// with a cross-org deny, so no caller could see one. See ADR 0001.
+//
+// The definition files are our own format, deliberately not .frame.md: that is
+// the external interchange shape framemd.go exists to translate, and a
+// frame-spec revision moving a heading must not be able to break a starter
+// document that has no reason to care.
+
+//go:embed builtins/*.yaml
+var builtinFS embed.FS
+
+// builtinFile is the on-disk shape of one definition. There is no `prefill`
+// key: built-ins carry rules and notes only, because shipping example prose
+// that every author has to delete makes the blank page worse rather than
+// better. Prefill is for org templates. Add the key when a built-in genuinely
+// needs it.
+type builtinFile struct {
+ ID string `yaml:"id"`
+ Title string `yaml:"title"`
+ Description string `yaml:"description"`
+ FieldRules map[string]FieldRule `yaml:"field_rules,omitempty"`
+}
+
+var builtins []Template
+
+func init() {
+ loaded, err := loadBuiltins()
+ if err != nil {
+ // A malformed starter must fail at startup, not at a user's first click
+ // on the picker. The files are compiled in, so a failure here is a build
+ // defect with no runtime recovery - the same reasoning as the
+ // package-level regexp.MustCompile calls in validate.go.
+ panic("frames: loading built-in templates: " + err.Error())
+ }
+ builtins = loaded
+}
+
+func loadBuiltins() ([]Template, error) {
+ entries, err := builtinFS.ReadDir("builtins")
+ if err != nil {
+ return nil, err
+ }
+ out := make([]Template, 0, len(entries))
+ seen := map[string]bool{}
+ for _, entry := range entries {
+ name := entry.Name()
+ b, err := builtinFS.ReadFile("builtins/" + name)
+ if err != nil {
+ return nil, err
+ }
+ var f builtinFile
+ if err := unmarshalStrict(b, &f); err != nil {
+ return nil, fmt.Errorf("%s: %w", name, err)
+ }
+ if !IsBuiltin(f.ID) {
+ return nil, fmt.Errorf("%s: id %q must start with %q", name, f.ID, BuiltinPrefix)
+ }
+ if seen[f.ID] {
+ return nil, fmt.Errorf("%s: duplicate id %q", name, f.ID)
+ }
+ seen[f.ID] = true
+ if strings.TrimSpace(f.Title) == "" {
+ return nil, fmt.Errorf("%s: title must not be empty", name)
+ }
+ if strings.TrimSpace(f.Description) == "" {
+ return nil, fmt.Errorf("%s: description must not be empty", name)
+ }
+ for key, rule := range f.FieldRules {
+ if _, ok := slotByKey(key); !ok {
+ return nil, fmt.Errorf("%s: field_rules names unknown slot %q", name, key)
+ }
+ if strings.TrimSpace(rule.Note) == "" {
+ return nil, fmt.Errorf("%s: field_rules[%s] has no note", name, key)
+ }
+ }
+ out = append(out, Template{
+ ID: f.ID, Title: f.Title, Description: f.Description, FieldRules: f.FieldRules,
+ })
+ }
+ // Blank leads: it is the "no template" escape hatch and must be easy to
+ // find. Everything else keeps filename order, which is alphabetical by
+ // title. SliceStable so that order is preserved rather than reshuffled.
+ sort.SliceStable(out, func(i, j int) bool {
+ return out[i].ID == BlankTemplateID && out[j].ID != BlankTemplateID
+ })
+ return out, nil
+}
+
+// BuiltinTemplates returns the templates compiled into the binary, Blank first.
+// Each call gets its own copy, rules map included: every request reads these, so
+// a caller must not be able to mutate the package's copy.
+func BuiltinTemplates() []Template {
+ out := make([]Template, 0, len(builtins))
+ for _, tmpl := range builtins {
+ out = append(out, copyTemplate(tmpl))
+ }
+ return out
+}
+
+// BuiltinTemplate looks one up by ID, reporting whether it exists.
+func BuiltinTemplate(id string) (Template, bool) {
+ for _, tmpl := range builtins {
+ if tmpl.ID == id {
+ return copyTemplate(tmpl), true
+ }
+ }
+ return Template{}, false
+}
+
+func copyTemplate(in Template) Template {
+ out := in
+ if in.FieldRules != nil {
+ out.FieldRules = make(map[string]FieldRule, len(in.FieldRules))
+ for key, rule := range in.FieldRules {
+ out.FieldRules[key] = rule
+ }
+ }
+ return out
+}
diff --git a/backend/internal/frames/builtins/blank.yaml b/backend/internal/frames/builtins/blank.yaml
new file mode 100644
index 0000000..ca5b271
--- /dev/null
+++ b/backend/internal/frames/builtins/blank.yaml
@@ -0,0 +1,5 @@
+# Blank is a real template rather than a special case in the picker, so that
+# starting from nothing takes the same code path as starting from a starter.
+id: builtin:blank
+title: Blank
+description: Start from an empty document and add only the sections you need.
diff --git a/backend/internal/frames/builtins/brand-voice.yaml b/backend/internal/frames/builtins/brand-voice.yaml
new file mode 100644
index 0000000..3483312
--- /dev/null
+++ b/backend/internal/frames/builtins/brand-voice.yaml
@@ -0,0 +1,13 @@
+id: builtin:brand-voice
+title: Brand Voice and Style
+description: How your organization sounds in writing, and the words it does and does not use.
+field_rules:
+ style:
+ level: required
+ note: Voice, tone, and formatting. Say what to do, not only what to avoid, and give an example of each.
+ terminology:
+ level: recommended
+ note: Product and brand names with their correct spelling and casing, plus any word you deliberately avoid.
+ rules:
+ level: recommended
+ note: Hard constraints, one per entry, that an assistant must never break when writing as your organization.
diff --git a/backend/internal/frames/builtins/business-process.yaml b/backend/internal/frames/builtins/business-process.yaml
new file mode 100644
index 0000000..427558e
--- /dev/null
+++ b/backend/internal/frames/builtins/business-process.yaml
@@ -0,0 +1,13 @@
+id: builtin:business-process
+title: Business Process
+description: How a piece of work moves through your organization, stage by stage.
+field_rules:
+ business_process:
+ level: required
+ note: The stages work passes through, who owns each, and what has to be true before it moves on.
+ terminology:
+ level: recommended
+ note: Names for stages, artifacts, and roles, so an assistant uses your words rather than generic ones.
+ goals:
+ level: recommended
+ note: What a good outcome looks like at the end of this process.
diff --git a/backend/internal/frames/builtins/domain-vocabulary.yaml b/backend/internal/frames/builtins/domain-vocabulary.yaml
new file mode 100644
index 0000000..370ff76
--- /dev/null
+++ b/backend/internal/frames/builtins/domain-vocabulary.yaml
@@ -0,0 +1,10 @@
+id: builtin:domain-vocabulary
+title: Domain Vocabulary
+description: The terms of art your organization uses and what they actually mean here.
+field_rules:
+ terminology:
+ level: required
+ note: One entry per term. Define it the way an insider would explain it to a new colleague, not the way a dictionary would.
+ rules:
+ level: recommended
+ note: Usage constraints worth stating, such as a term that is often confused with another.
diff --git a/backend/internal/frames/builtins/engineering-norms.yaml b/backend/internal/frames/builtins/engineering-norms.yaml
new file mode 100644
index 0000000..fce47ee
--- /dev/null
+++ b/backend/internal/frames/builtins/engineering-norms.yaml
@@ -0,0 +1,13 @@
+id: builtin:engineering-norms
+title: Engineering Norms
+description: How your team expects software to be built, reviewed, and shipped.
+field_rules:
+ norms:
+ level: required
+ note: "Ways of working: review expectations, testing standards, what \"done\" means, and how work gets committed."
+ rules:
+ level: recommended
+ note: "Hard constraints, one per entry, that must hold regardless of context."
+ architecture:
+ level: recommended
+ note: "The structure worth knowing before changing anything: services, boundaries, and what depends on what."
diff --git a/backend/internal/frames/builtins/product-context.yaml b/backend/internal/frames/builtins/product-context.yaml
new file mode 100644
index 0000000..ae6763c
--- /dev/null
+++ b/backend/internal/frames/builtins/product-context.yaml
@@ -0,0 +1,16 @@
+id: builtin:product-context
+title: Product Context
+description: What you are building, who it is for, and what success looks like.
+field_rules:
+ goals:
+ level: required
+ note: What good outcomes look like for work done in this product, and how you would know you got one.
+ architecture:
+ level: recommended
+ note: The system's shape at the level a newcomer needs before touching it.
+ terminology:
+ level: recommended
+ note: Product-specific names for features, objects, and user types.
+ business_process:
+ level: recommended
+ note: How a change reaches users, if that is not obvious.
diff --git a/backend/internal/frames/builtins_test.go b/backend/internal/frames/builtins_test.go
new file mode 100644
index 0000000..9aaf9fd
--- /dev/null
+++ b/backend/internal/frames/builtins_test.go
@@ -0,0 +1,106 @@
+package frames
+
+import "testing"
+
+func TestBuiltinTemplatesLoad(t *testing.T) {
+ got := BuiltinTemplates()
+ if len(got) < 6 {
+ t.Fatalf("loaded %d built-ins, want at least 6", len(got))
+ }
+ // Blank leads the picker: it is the escape hatch, and burying it among the
+ // starters makes it hard to find.
+ if got[0].ID != BlankTemplateID {
+ t.Errorf("first built-in is %q, want %q", got[0].ID, BlankTemplateID)
+ }
+ if len(got[0].FieldRules) != 0 {
+ t.Errorf("blank carries %d rules, want none", len(got[0].FieldRules))
+ }
+}
+
+func TestBuiltinTemplatesAreWellFormed(t *testing.T) {
+ seen := map[string]bool{}
+ for _, tmpl := range BuiltinTemplates() {
+ t.Run(tmpl.ID, func(t *testing.T) {
+ if !IsBuiltin(tmpl.ID) {
+ t.Errorf("id %q does not start with %q", tmpl.ID, BuiltinPrefix)
+ }
+ if seen[tmpl.ID] {
+ t.Errorf("duplicate id %q", tmpl.ID)
+ }
+ seen[tmpl.ID] = true
+ if tmpl.Title == "" {
+ t.Error("title is empty")
+ }
+ if tmpl.Description == "" {
+ t.Error("description is empty")
+ }
+ // A built-in carrying prefill would mean shipping placeholder prose
+ // authors have to delete. See the note in builtins.go.
+ if len(tmpl.Prefill.Slots.Rules) > 0 || tmpl.Prefill.Slots.Style != "" ||
+ len(tmpl.Prefill.Slots.Terminology) > 0 || len(tmpl.Prefill.Extends) > 0 {
+ t.Error("built-in carries prefill, which built-ins deliberately do not")
+ }
+ for key, rule := range tmpl.FieldRules {
+ if _, ok := slotByKey(key); !ok {
+ t.Errorf("rule names unknown slot %q", key)
+ }
+ // A rule with no note is a missed opportunity: the note is what
+ // the seeded form shows and what an MCP interview asks.
+ if rule.Note == "" {
+ t.Errorf("rule for %q has no note", key)
+ }
+ }
+ })
+ }
+}
+
+func TestBuiltinTemplateLookup(t *testing.T) {
+ tests := []struct {
+ id string
+ found bool
+ }{
+ {id: BlankTemplateID, found: true},
+ {id: "builtin:domain-vocabulary", found: true},
+ {id: "builtin:nope", found: false},
+ {id: "01J000000000000000000000AB", found: false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.id, func(t *testing.T) {
+ got, ok := BuiltinTemplate(tt.id)
+ if ok != tt.found {
+ t.Fatalf("found = %v, want %v", ok, tt.found)
+ }
+ if ok && got.ID != tt.id {
+ t.Errorf("returned id %q, want %q", got.ID, tt.id)
+ }
+ })
+ }
+}
+
+func TestBuiltinTemplatesAreCopiedPerCall(t *testing.T) {
+ // Every request reads these, so a caller must not be able to mutate the
+ // package's own copy.
+ first := BuiltinTemplates()
+ target := ""
+ for _, tmpl := range first {
+ if len(tmpl.FieldRules) > 0 {
+ target = tmpl.ID
+ for key := range tmpl.FieldRules {
+ tmpl.FieldRules[key] = FieldRule{Level: RequirementOptional, Note: "clobbered"}
+ }
+ break
+ }
+ }
+ if target == "" {
+ t.Fatal("no built-in has rules, so this test proves nothing")
+ }
+ again, ok := BuiltinTemplate(target)
+ if !ok {
+ t.Fatalf("%s vanished", target)
+ }
+ for key, rule := range again.FieldRules {
+ if rule.Note == "clobbered" {
+ t.Fatalf("mutating a returned template changed the package copy at %q", key)
+ }
+ }
+}
From 022627bc2544beb7b5fd7633e3daf811c5bcc976 Mon Sep 17 00:00:00 2001
From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com>
Date: Mon, 7 Sep 2026 12:18:00 +0200
Subject: [PATCH 04/43] feat(store): add frame template persistence to
Repository and the in-memory fake
---
backend/internal/store/memory.go | 116 ++++++++++++++--
backend/internal/store/memory_test.go | 185 ++++++++++++++++++++++++++
backend/internal/store/store.go | 37 ++++++
3 files changed, 324 insertions(+), 14 deletions(-)
diff --git a/backend/internal/store/memory.go b/backend/internal/store/memory.go
index 270fd0e..27bdfb7 100644
--- a/backend/internal/store/memory.go
+++ b/backend/internal/store/memory.go
@@ -2,6 +2,7 @@ package store
import (
"context"
+ "sort"
"strings"
"sync"
@@ -10,14 +11,15 @@ import (
// Memory is an in-memory Repository for development and tests.
type Memory struct {
- mu sync.RWMutex
- orgs map[string]*framesv1.Org // id -> org
- slugToOrg map[string]string // slug -> id
- memberships []*framesv1.Membership // active + pending; active rows have non-empty UserSub
- frames map[string]*framesv1.Frame // id -> frame
- keyToFrame map[string]string // orgID+"/"+name -> id
- versions map[string]*frameVersionRow // frameID+"@"+version
- grants map[string][]Grant // frameID -> grants
+ mu sync.RWMutex
+ orgs map[string]*framesv1.Org // id -> org
+ slugToOrg map[string]string // slug -> id
+ memberships []*framesv1.Membership // active + pending; active rows have non-empty UserSub
+ frames map[string]*framesv1.Frame // id -> frame
+ keyToFrame map[string]string // orgID+"/"+name -> id
+ versions map[string]*frameVersionRow // frameID+"@"+version
+ grants map[string][]Grant // frameID -> grants
+ frameTemplates map[string]*FrameTemplate // id -> row
}
type frameVersionRow struct {
@@ -30,12 +32,13 @@ var _ Repository = (*Memory)(nil)
func NewMemory() *Memory {
return &Memory{
- orgs: map[string]*framesv1.Org{},
- slugToOrg: map[string]string{},
- frames: map[string]*framesv1.Frame{},
- keyToFrame: map[string]string{},
- versions: map[string]*frameVersionRow{},
- grants: map[string][]Grant{},
+ orgs: map[string]*framesv1.Org{},
+ slugToOrg: map[string]string{},
+ frames: map[string]*framesv1.Frame{},
+ keyToFrame: map[string]string{},
+ versions: map[string]*frameVersionRow{},
+ grants: map[string][]Grant{},
+ frameTemplates: map[string]*FrameTemplate{},
}
}
@@ -370,3 +373,88 @@ func (m *Memory) DeleteFrame(_ context.Context, frameID string) error {
}
return nil
}
+
+// copyFrameTemplate returns an independent copy. Callers mutate what they are
+// handed (a handler applies an update to it), so the store must not share its
+// own row - the byte slices included.
+func copyFrameTemplate(in *FrameTemplate) *FrameTemplate {
+ out := *in
+ out.Prefill = append([]byte(nil), in.Prefill...)
+ out.FieldRules = append([]byte(nil), in.FieldRules...)
+ return &out
+}
+
+// titleTaken reports whether another row in orgID already uses title. The real
+// schema enforces this with UNIQUE (org_id, title); the fake has to enforce it
+// too, or a test passes here and fails against SQLite.
+func (m *Memory) titleTaken(orgID, title, exceptID string) bool {
+ for _, row := range m.frameTemplates {
+ if row.OrgID == orgID && row.Title == title && row.ID != exceptID {
+ return true
+ }
+ }
+ return false
+}
+
+func (m *Memory) CreateFrameTemplate(_ context.Context, t *FrameTemplate) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if _, ok := m.frameTemplates[t.ID]; ok {
+ return ErrAlreadyExists
+ }
+ if m.titleTaken(t.OrgID, t.Title, "") {
+ return ErrAlreadyExists
+ }
+ m.frameTemplates[t.ID] = copyFrameTemplate(t)
+ return nil
+}
+
+func (m *Memory) UpdateFrameTemplate(_ context.Context, t *FrameTemplate) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ existing, ok := m.frameTemplates[t.ID]
+ if !ok || existing.OrgID != t.OrgID {
+ return ErrNotFound
+ }
+ if m.titleTaken(t.OrgID, t.Title, t.ID) {
+ return ErrAlreadyExists
+ }
+ m.frameTemplates[t.ID] = copyFrameTemplate(t)
+ return nil
+}
+
+func (m *Memory) DeleteFrameTemplate(_ context.Context, orgID, id string) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ existing, ok := m.frameTemplates[id]
+ if !ok || existing.OrgID != orgID {
+ return ErrNotFound
+ }
+ delete(m.frameTemplates, id)
+ return nil
+}
+
+func (m *Memory) GetFrameTemplate(_ context.Context, orgID, id string) (*FrameTemplate, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ row, ok := m.frameTemplates[id]
+ if !ok || row.OrgID != orgID {
+ return nil, ErrNotFound
+ }
+ return copyFrameTemplate(row), nil
+}
+
+func (m *Memory) ListFrameTemplatesByOrg(_ context.Context, orgID string) ([]*FrameTemplate, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ out := []*FrameTemplate{}
+ for _, row := range m.frameTemplates {
+ if row.OrgID == orgID {
+ out = append(out, copyFrameTemplate(row))
+ }
+ }
+ // Deterministic order, so a picker does not reshuffle between requests.
+ // SQLite orders by title too.
+ sort.Slice(out, func(i, j int) bool { return out[i].Title < out[j].Title })
+ return out, nil
+}
diff --git a/backend/internal/store/memory_test.go b/backend/internal/store/memory_test.go
index d5fabfa..51c3028 100644
--- a/backend/internal/store/memory_test.go
+++ b/backend/internal/store/memory_test.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"testing"
+ "time"
"github.com/nebari-dev/nebari-frames/backend/internal/store"
framesv1 "github.com/nebari-dev/nebari-frames/gen/go/frames/v1"
@@ -240,3 +241,187 @@ func TestMemory_CreateFrameVersionAndGrants(t *testing.T) {
t.Run(tt.name, tt.run)
}
}
+
+func TestMemoryFrameTemplateCRUD(t *testing.T) {
+ ctx := context.Background()
+ m := store.NewMemory()
+ row := &store.FrameTemplate{
+ ID: "t1", OrgID: "org-a", Title: "Brand Voice", Description: "How we sound",
+ Prefill: []byte("slots: {}\n"), FieldRules: []byte(`{"style":{"level":"required"}}`),
+ CreatedBy: "user-1", CreatedAt: time.Unix(0, 0).UTC(), UpdatedAt: time.Unix(0, 0).UTC(),
+ }
+ if err := m.CreateFrameTemplate(ctx, row); err != nil {
+ t.Fatalf("create: %v", err)
+ }
+
+ got, err := m.GetFrameTemplate(ctx, "org-a", "t1")
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ if got.Title != "Brand Voice" || string(got.FieldRules) != `{"style":{"level":"required"}}` {
+ t.Errorf("round trip lost data: %+v", got)
+ }
+
+ // A caller in another org must not see it, and must not learn it exists.
+ if _, err := m.GetFrameTemplate(ctx, "org-b", "t1"); !errors.Is(err, store.ErrNotFound) {
+ t.Errorf("cross-org get error = %v, want ErrNotFound", err)
+ }
+ rows, err := m.ListFrameTemplatesByOrg(ctx, "org-b")
+ if err != nil {
+ t.Fatalf("list org-b: %v", err)
+ }
+ if len(rows) != 0 {
+ t.Errorf("org-b sees %d templates, want 0", len(rows))
+ }
+
+ rows, err = m.ListFrameTemplatesByOrg(ctx, "org-a")
+ if err != nil {
+ t.Fatalf("list org-a: %v", err)
+ }
+ if len(rows) != 1 || rows[0].ID != "t1" {
+ t.Fatalf("org-a list = %+v, want one row t1", rows)
+ }
+
+ row.Title = "Brand Voice v2"
+ row.UpdatedAt = time.Unix(60, 0).UTC()
+ if err := m.UpdateFrameTemplate(ctx, row); err != nil {
+ t.Fatalf("update: %v", err)
+ }
+ got, err = m.GetFrameTemplate(ctx, "org-a", "t1")
+ if err != nil {
+ t.Fatalf("get after update: %v", err)
+ }
+ if got.Title != "Brand Voice v2" {
+ t.Errorf("title = %q, want the updated one", got.Title)
+ }
+
+ if err := m.DeleteFrameTemplate(ctx, "org-a", "t1"); err != nil {
+ t.Fatalf("delete: %v", err)
+ }
+ if _, err := m.GetFrameTemplate(ctx, "org-a", "t1"); !errors.Is(err, store.ErrNotFound) {
+ t.Errorf("get after delete = %v, want ErrNotFound", err)
+ }
+}
+
+func TestMemoryFrameTemplateConstraints(t *testing.T) {
+ ctx := context.Background()
+ base := func(id, orgID, title string) *store.FrameTemplate {
+ return &store.FrameTemplate{ID: id, OrgID: orgID, Title: title, Prefill: []byte("slots: {}\n"), FieldRules: []byte("{}")}
+ }
+ tests := []struct {
+ name string
+ seed []*store.FrameTemplate
+ op func(*store.Memory) error
+ wantErr error
+ }{
+ {
+ name: "duplicate title in the same org is rejected",
+ seed: []*store.FrameTemplate{base("t1", "org-a", "Brand Voice")},
+ op: func(m *store.Memory) error {
+ return m.CreateFrameTemplate(context.Background(), base("t2", "org-a", "Brand Voice"))
+ },
+ wantErr: store.ErrAlreadyExists,
+ },
+ {
+ name: "the same title in a different org is fine",
+ seed: []*store.FrameTemplate{base("t1", "org-a", "Brand Voice")},
+ op: func(m *store.Memory) error {
+ return m.CreateFrameTemplate(context.Background(), base("t2", "org-b", "Brand Voice"))
+ },
+ },
+ {
+ name: "duplicate id is rejected",
+ seed: []*store.FrameTemplate{base("t1", "org-a", "Brand Voice")},
+ op: func(m *store.Memory) error {
+ return m.CreateFrameTemplate(context.Background(), base("t1", "org-a", "Other"))
+ },
+ wantErr: store.ErrAlreadyExists,
+ },
+ {
+ name: "renaming onto another row's title is rejected",
+ seed: []*store.FrameTemplate{base("t1", "org-a", "Brand Voice"), base("t2", "org-a", "Engineering Norms")},
+ op: func(m *store.Memory) error {
+ return m.UpdateFrameTemplate(context.Background(), base("t2", "org-a", "Brand Voice"))
+ },
+ wantErr: store.ErrAlreadyExists,
+ },
+ {
+ name: "renaming a row to its own title is fine",
+ seed: []*store.FrameTemplate{base("t1", "org-a", "Brand Voice")},
+ op: func(m *store.Memory) error {
+ return m.UpdateFrameTemplate(context.Background(), base("t1", "org-a", "Brand Voice"))
+ },
+ },
+ {
+ name: "updating a missing row is not found",
+ op: func(m *store.Memory) error {
+ return m.UpdateFrameTemplate(context.Background(), base("nope", "org-a", "X"))
+ },
+ wantErr: store.ErrNotFound,
+ },
+ {
+ name: "updating another org's row is not found",
+ seed: []*store.FrameTemplate{base("t1", "org-a", "Brand Voice")},
+ op: func(m *store.Memory) error {
+ return m.UpdateFrameTemplate(context.Background(), base("t1", "org-b", "Brand Voice"))
+ },
+ wantErr: store.ErrNotFound,
+ },
+ {
+ name: "deleting a missing row is not found",
+ op: func(m *store.Memory) error { return m.DeleteFrameTemplate(context.Background(), "org-a", "nope") },
+ wantErr: store.ErrNotFound,
+ },
+ {
+ name: "deleting another org's row is not found",
+ seed: []*store.FrameTemplate{base("t1", "org-a", "Brand Voice")},
+ op: func(m *store.Memory) error { return m.DeleteFrameTemplate(context.Background(), "org-b", "t1") },
+ wantErr: store.ErrNotFound,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ m := store.NewMemory()
+ for _, row := range tt.seed {
+ if err := m.CreateFrameTemplate(ctx, row); err != nil {
+ t.Fatalf("seeding %s: %v", row.ID, err)
+ }
+ }
+ err := tt.op(m)
+ if tt.wantErr == nil {
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ return
+ }
+ if !errors.Is(err, tt.wantErr) {
+ t.Fatalf("err = %v, want %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestMemoryFrameTemplateReturnsCopies(t *testing.T) {
+ // A handler applying an update mutates the struct it was handed, so the
+ // store must not be sharing its own row.
+ ctx := context.Background()
+ m := store.NewMemory()
+ if err := m.CreateFrameTemplate(ctx, &store.FrameTemplate{
+ ID: "t1", OrgID: "org-a", Title: "Brand Voice",
+ Prefill: []byte("slots: {}\n"), FieldRules: []byte("{}"),
+ }); err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ got, err := m.GetFrameTemplate(ctx, "org-a", "t1")
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ got.Title = "clobbered"
+ again, err := m.GetFrameTemplate(ctx, "org-a", "t1")
+ if err != nil {
+ t.Fatalf("get again: %v", err)
+ }
+ if again.Title != "Brand Voice" {
+ t.Errorf("mutating the returned row changed the store: %q", again.Title)
+ }
+}
diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go
index 275ab1a..d440fd3 100644
--- a/backend/internal/store/store.go
+++ b/backend/internal/store/store.go
@@ -7,6 +7,7 @@ import (
"context"
"errors"
"strings"
+ "time"
framesv1 "github.com/nebari-dev/nebari-frames/gen/go/frames/v1"
)
@@ -42,6 +43,31 @@ type ParentEdge struct {
OrderIndex int
}
+// FrameTemplate is one org-authored template row.
+//
+// A store struct rather than a protobuf message, unlike Frame and Membership:
+// the proto is the external representation, and the template model is
+// deliberately owned internally with translation at the boundary. Grant and
+// ParentEdge above are store structs for the same reason.
+//
+// Built-in templates are not rows at all. They are compiled into the binary
+// (frames.BuiltinTemplates), so nothing in this package knows about them.
+//
+// Prefill and FieldRules are opaque blobs here. Keeping them as bytes means
+// adding a slot to frames.SlotTable never touches this schema, matching how
+// frame_versions.content is stored.
+type FrameTemplate struct {
+ ID string
+ OrgID string
+ Title string
+ Description string
+ Prefill []byte // canonical YAML: slots + extends
+ FieldRules []byte // JSON: slot key -> {level, note}
+ CreatedBy string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
// CreateFrameVersionInput carries everything a publish needs to insert
// atomically: the frame row (created if new), the version, its inheritance
// edges, and its default grants.
@@ -86,4 +112,15 @@ type Repository interface {
FrameChildren(ctx context.Context, parentFrameID string) ([]*framesv1.Frame, error)
DeleteFrame(ctx context.Context, frameID string) error
+
+ // Frame templates. Every accessor takes orgID because org scoping is the
+ // only access control a template has: making it impossible to ask for a row
+ // without saying who is asking beats relying on each handler to remember. A
+ // row belonging to another org reports ErrNotFound rather than a denial, so
+ // existence does not leak.
+ CreateFrameTemplate(ctx context.Context, t *FrameTemplate) error
+ UpdateFrameTemplate(ctx context.Context, t *FrameTemplate) error
+ DeleteFrameTemplate(ctx context.Context, orgID, id string) error
+ GetFrameTemplate(ctx context.Context, orgID, id string) (*FrameTemplate, error)
+ ListFrameTemplatesByOrg(ctx context.Context, orgID string) ([]*FrameTemplate, error)
}
From 655262c3d2af46daaa8f7073cb599aff14704aec Mon Sep 17 00:00:00 2001
From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com>
Date: Mon, 7 Sep 2026 12:23:54 +0200
Subject: [PATCH 05/43] test(store): make frame template copy test catch slice
aliasing
---
backend/internal/store/memory_test.go | 41 +++++++++++++++++++++++----
1 file changed, 36 insertions(+), 5 deletions(-)
diff --git a/backend/internal/store/memory_test.go b/backend/internal/store/memory_test.go
index 51c3028..7357945 100644
--- a/backend/internal/store/memory_test.go
+++ b/backend/internal/store/memory_test.go
@@ -404,24 +404,55 @@ func TestMemoryFrameTemplateConstraints(t *testing.T) {
func TestMemoryFrameTemplateReturnsCopies(t *testing.T) {
// A handler applying an update mutates the struct it was handed, so the
// store must not be sharing its own row.
+ //
+ // The byte slices matter more than the scalars here. `out := *in` already
+ // isolates a string field, so asserting only on Title would pass even if the
+ // slice copies were deleted, and the aliasing bug this test exists to catch
+ // would walk straight back in. Both slices are therefore written through by
+ // index: indexing can only reach shared backing memory, whereas an append
+ // might quietly reallocate and prove nothing.
ctx := context.Background()
m := store.NewMemory()
- if err := m.CreateFrameTemplate(ctx, &store.FrameTemplate{
+ const wantPrefill = "slots: {}\n"
+ const wantRules = `{"style":{"level":"required"}}`
+ input := &store.FrameTemplate{
ID: "t1", OrgID: "org-a", Title: "Brand Voice",
- Prefill: []byte("slots: {}\n"), FieldRules: []byte("{}"),
- }); err != nil {
+ Prefill: []byte(wantPrefill), FieldRules: []byte(wantRules),
+ }
+ if err := m.CreateFrameTemplate(ctx, input); err != nil {
t.Fatalf("create: %v", err)
}
+
+ // The write path too: a caller that keeps hold of what it passed to Create
+ // must not be able to reach into the stored row through it.
+ input.Title = "clobbered on the way in"
+ input.Prefill[0] = 'X'
+ input.FieldRules[0] = 'X'
+
got, err := m.GetFrameTemplate(ctx, "org-a", "t1")
if err != nil {
t.Fatalf("get: %v", err)
}
- got.Title = "clobbered"
+ if got.Title != "Brand Voice" || string(got.Prefill) != wantPrefill || string(got.FieldRules) != wantRules {
+ t.Fatalf("Create aliased its input: %+v", got)
+ }
+
+ // And the read path.
+ got.Title = "clobbered on the way out"
+ got.Prefill[0] = 'Y'
+ got.FieldRules[0] = 'Y'
+
again, err := m.GetFrameTemplate(ctx, "org-a", "t1")
if err != nil {
t.Fatalf("get again: %v", err)
}
if again.Title != "Brand Voice" {
- t.Errorf("mutating the returned row changed the store: %q", again.Title)
+ t.Errorf("mutating the returned title changed the store: %q", again.Title)
+ }
+ if string(again.Prefill) != wantPrefill {
+ t.Errorf("mutating the returned prefill changed the store: %q", again.Prefill)
+ }
+ if string(again.FieldRules) != wantRules {
+ t.Errorf("mutating the returned field rules changed the store: %q", again.FieldRules)
}
}
From 22aa74519b0140ee82a689a6db74563a1b6790a8 Mon Sep 17 00:00:00 2001
From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com>
Date: Mon, 7 Sep 2026 12:30:24 +0200
Subject: [PATCH 06/43] feat(store): persist frame templates in SQLite
---
.../sqlite/migrations/006_frame_templates.sql | 25 +++
backend/internal/store/sqlite/sqlite.go | 116 ++++++++++++++
backend/internal/store/sqlite/sqlite_test.go | 150 ++++++++++++++++++
3 files changed, 291 insertions(+)
create mode 100644 backend/internal/store/sqlite/migrations/006_frame_templates.sql
diff --git a/backend/internal/store/sqlite/migrations/006_frame_templates.sql b/backend/internal/store/sqlite/migrations/006_frame_templates.sql
new file mode 100644
index 0000000..3d248eb
--- /dev/null
+++ b/backend/internal/store/sqlite/migrations/006_frame_templates.sql
@@ -0,0 +1,25 @@
+-- +goose Up
+-- Frame templates are an authoring affordance, not Frames: they are copied once
+-- at creation and nothing is recorded on the Frame that came from one, so there
+-- is no foreign key back to frames and no version history here. See
+-- docs/adr/0001-frame-templates-are-not-frames.md.
+--
+-- Built-in templates are NOT rows. They are compiled into the binary, because
+-- seeding them per org would mean reconciling N rows on every upgrade.
+CREATE TABLE frame_templates (
+ id TEXT PRIMARY KEY,
+ org_id TEXT NOT NULL REFERENCES orgs(id),
+ title TEXT NOT NULL,
+ description TEXT NOT NULL,
+ -- Opaque blobs so that adding a slot to frames.SlotTable never touches this
+ -- schema, the same reasoning as frame_versions.content.
+ prefill BLOB NOT NULL, -- canonical YAML: slots + extends
+ field_rules BLOB NOT NULL, -- JSON: slot key -> {level, note}
+ created_by TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ UNIQUE (org_id, title)
+);
+
+-- +goose Down
+DROP TABLE frame_templates;
diff --git a/backend/internal/store/sqlite/sqlite.go b/backend/internal/store/sqlite/sqlite.go
index 0aac913..be00a50 100644
--- a/backend/internal/store/sqlite/sqlite.go
+++ b/backend/internal/store/sqlite/sqlite.go
@@ -541,6 +541,122 @@ func (r *Repository) FrameChildren(ctx context.Context, parentFrameID string) ([
return out, rows.Err()
}
+// Frame templates. Prefill and field_rules are stored as written: this layer
+// does not know or care what is inside them, which is what keeps a slot change
+// out of this schema.
+
+const frameTemplateCols = `id, org_id, title, description, prefill, field_rules, created_by, created_at, updated_at`
+
+func (r *Repository) CreateFrameTemplate(ctx context.Context, t *store.FrameTemplate) error {
+ _, err := r.db.ExecContext(ctx,
+ `INSERT INTO frame_templates (`+frameTemplateCols+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ t.ID, t.OrgID, t.Title, t.Description, t.Prefill, t.FieldRules, t.CreatedBy,
+ t.CreatedAt.UTC().Format(time.RFC3339), t.UpdatedAt.UTC().Format(time.RFC3339))
+ if err != nil {
+ // Both the primary key and UNIQUE (org_id, title) land here. The handler
+ // needs a domain error to map, not a driver string.
+ if isUnique(err) {
+ return store.ErrAlreadyExists
+ }
+ return err
+ }
+ return nil
+}
+
+func (r *Repository) UpdateFrameTemplate(ctx context.Context, t *store.FrameTemplate) error {
+ // org_id is in the WHERE clause rather than checked beforehand, so a row in
+ // another org is simply not matched: no separate read, no race between the
+ // check and the write.
+ res, err := r.db.ExecContext(ctx,
+ `UPDATE frame_templates
+ SET title = ?, description = ?, prefill = ?, field_rules = ?, updated_at = ?
+ WHERE id = ? AND org_id = ?`,
+ t.Title, t.Description, t.Prefill, t.FieldRules,
+ t.UpdatedAt.UTC().Format(time.RFC3339), t.ID, t.OrgID)
+ if err != nil {
+ if isUnique(err) {
+ return store.ErrAlreadyExists
+ }
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return store.ErrNotFound
+ }
+ return nil
+}
+
+func (r *Repository) DeleteFrameTemplate(ctx context.Context, orgID, id string) error {
+ res, err := r.db.ExecContext(ctx,
+ `DELETE FROM frame_templates WHERE id = ? AND org_id = ?`, id, orgID)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return store.ErrNotFound
+ }
+ return nil
+}
+
+func (r *Repository) GetFrameTemplate(ctx context.Context, orgID, id string) (*store.FrameTemplate, error) {
+ row := r.db.QueryRowContext(ctx,
+ `SELECT `+frameTemplateCols+` FROM frame_templates WHERE id = ? AND org_id = ?`, id, orgID)
+ t, err := scanFrameTemplate(row)
+ if errors.Is(err, sql.ErrNoRows) {
+ // A row in another org is reported as absent, so existence does not leak.
+ return nil, store.ErrNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ return t, nil
+}
+
+func (r *Repository) ListFrameTemplatesByOrg(ctx context.Context, orgID string) ([]*store.FrameTemplate, error) {
+ rows, err := r.db.QueryContext(ctx,
+ `SELECT `+frameTemplateCols+` FROM frame_templates WHERE org_id = ? ORDER BY title`, orgID)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+ out := []*store.FrameTemplate{}
+ for rows.Next() {
+ t, err := scanFrameTemplate(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, t)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+// rowScanner is satisfied by both *sql.Row and *sql.Rows, so the column list is
+// written once. There is no existing scanner interface in this file: scanOrg and
+// scanMemberships each take a concrete type instead, one per shape they scan.
+type rowScanner interface{ Scan(dest ...any) error }
+
+func scanFrameTemplate(s rowScanner) (*store.FrameTemplate, error) {
+ var t store.FrameTemplate
+ var created, updated string
+ if err := s.Scan(&t.ID, &t.OrgID, &t.Title, &t.Description, &t.Prefill, &t.FieldRules,
+ &t.CreatedBy, &created, &updated); err != nil {
+ return nil, err
+ }
+ t.CreatedAt = ts(created).AsTime()
+ t.UpdatedAt = ts(updated).AsTime()
+ return &t, nil
+}
+
func (r *Repository) DeleteFrame(ctx context.Context, frameID string) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
diff --git a/backend/internal/store/sqlite/sqlite_test.go b/backend/internal/store/sqlite/sqlite_test.go
index 915de8c..e07df90 100644
--- a/backend/internal/store/sqlite/sqlite_test.go
+++ b/backend/internal/store/sqlite/sqlite_test.go
@@ -790,3 +790,153 @@ func TestMembershipEmailsAreCanonical(t *testing.T) {
}
})
}
+
+func TestSQLiteFrameTemplateCRUD(t *testing.T) {
+ ctx := context.Background()
+ r := newRepo(t) // existing helper in this file: opens a migrated DB under t.TempDir()
+ // A template references an org, so one has to exist.
+ if err := r.CreateOrg(ctx, &framesv1.Org{
+ Id: "org-a", Slug: "acme", DisplayName: "Acme", CreatedAt: timestamppb.Now(),
+ }); err != nil {
+ t.Fatalf("create org: %v", err)
+ }
+ if err := r.CreateOrg(ctx, &framesv1.Org{
+ Id: "org-b", Slug: "other", DisplayName: "Other", CreatedAt: timestamppb.Now(),
+ }); err != nil {
+ t.Fatalf("create other org: %v", err)
+ }
+
+ created := time.Unix(1700000000, 0).UTC()
+ row := &store.FrameTemplate{
+ ID: "t1", OrgID: "org-a", Title: "Brand Voice", Description: "How we sound",
+ Prefill: []byte("slots:\n style: Plain sentences.\n"),
+ FieldRules: []byte(`{"style":{"level":"required","note":"Voice and tone."}}`),
+ CreatedBy: "user-1", CreatedAt: created, UpdatedAt: created,
+ }
+ if err := r.CreateFrameTemplate(ctx, row); err != nil {
+ t.Fatalf("create: %v", err)
+ }
+
+ got, err := r.GetFrameTemplate(ctx, "org-a", "t1")
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ if got.Title != "Brand Voice" || got.CreatedBy != "user-1" {
+ t.Errorf("round trip lost scalars: %+v", got)
+ }
+ if string(got.Prefill) != string(row.Prefill) {
+ t.Errorf("prefill = %q, want %q", got.Prefill, row.Prefill)
+ }
+ if string(got.FieldRules) != string(row.FieldRules) {
+ t.Errorf("field_rules = %q, want %q", got.FieldRules, row.FieldRules)
+ }
+ if !got.CreatedAt.Equal(created) {
+ t.Errorf("created_at = %v, want %v", got.CreatedAt, created)
+ }
+
+ if _, err := r.GetFrameTemplate(ctx, "org-b", "t1"); !errors.Is(err, store.ErrNotFound) {
+ t.Errorf("cross-org get = %v, want ErrNotFound", err)
+ }
+
+ row.Title = "Brand Voice v2"
+ row.UpdatedAt = created.Add(time.Minute)
+ if err := r.UpdateFrameTemplate(ctx, row); err != nil {
+ t.Fatalf("update: %v", err)
+ }
+ got, err = r.GetFrameTemplate(ctx, "org-a", "t1")
+ if err != nil {
+ t.Fatalf("get after update: %v", err)
+ }
+ if got.Title != "Brand Voice v2" || !got.UpdatedAt.Equal(created.Add(time.Minute)) {
+ t.Errorf("update not applied: %+v", got)
+ }
+
+ if err := r.DeleteFrameTemplate(ctx, "org-a", "t1"); err != nil {
+ t.Fatalf("delete: %v", err)
+ }
+ if _, err := r.GetFrameTemplate(ctx, "org-a", "t1"); !errors.Is(err, store.ErrNotFound) {
+ t.Errorf("get after delete = %v, want ErrNotFound", err)
+ }
+}
+
+func TestSQLiteFrameTemplateConstraints(t *testing.T) {
+ ctx := context.Background()
+ r := newRepo(t)
+ for _, id := range []string{"org-a", "org-b"} {
+ if err := r.CreateOrg(ctx, &framesv1.Org{
+ Id: id, Slug: id, DisplayName: id, CreatedAt: timestamppb.Now(),
+ }); err != nil {
+ t.Fatalf("create org %s: %v", id, err)
+ }
+ }
+ mk := func(id, orgID, title string) *store.FrameTemplate {
+ now := time.Unix(1700000000, 0).UTC()
+ return &store.FrameTemplate{
+ ID: id, OrgID: orgID, Title: title, Description: "d",
+ Prefill: []byte("slots: {}\n"), FieldRules: []byte("{}"),
+ CreatedBy: "u", CreatedAt: now, UpdatedAt: now,
+ }
+ }
+ if err := r.CreateFrameTemplate(ctx, mk("t1", "org-a", "Brand Voice")); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+
+ // The UNIQUE (org_id, title) constraint has to surface as ErrAlreadyExists,
+ // not a raw driver error, or the handler cannot map it to a useful code.
+ if err := r.CreateFrameTemplate(ctx, mk("t2", "org-a", "Brand Voice")); !errors.Is(err, store.ErrAlreadyExists) {
+ t.Errorf("duplicate title in org = %v, want ErrAlreadyExists", err)
+ }
+ // The control: the same title in another org must succeed, or the test above
+ // would pass for the wrong reason.
+ if err := r.CreateFrameTemplate(ctx, mk("t3", "org-b", "Brand Voice")); err != nil {
+ t.Errorf("same title in another org = %v, want success", err)
+ }
+ if err := r.CreateFrameTemplate(ctx, mk("t1", "org-a", "Different")); !errors.Is(err, store.ErrAlreadyExists) {
+ t.Errorf("duplicate id = %v, want ErrAlreadyExists", err)
+ }
+ if err := r.UpdateFrameTemplate(ctx, mk("nope", "org-a", "X")); !errors.Is(err, store.ErrNotFound) {
+ t.Errorf("update missing = %v, want ErrNotFound", err)
+ }
+ if err := r.UpdateFrameTemplate(ctx, mk("t1", "org-b", "Brand Voice")); !errors.Is(err, store.ErrNotFound) {
+ t.Errorf("update across orgs = %v, want ErrNotFound", err)
+ }
+ if err := r.DeleteFrameTemplate(ctx, "org-b", "t1"); !errors.Is(err, store.ErrNotFound) {
+ t.Errorf("delete across orgs = %v, want ErrNotFound", err)
+ }
+}
+
+func TestSQLiteFrameTemplateListIsOrgScopedAndOrdered(t *testing.T) {
+ ctx := context.Background()
+ r := newRepo(t)
+ for _, id := range []string{"org-a", "org-b"} {
+ if err := r.CreateOrg(ctx, &framesv1.Org{
+ Id: id, Slug: id, DisplayName: id, CreatedAt: timestamppb.Now(),
+ }); err != nil {
+ t.Fatalf("create org %s: %v", id, err)
+ }
+ }
+ now := time.Unix(1700000000, 0).UTC()
+ for _, spec := range []struct{ id, org, title string }{
+ {"t1", "org-a", "Zebra"},
+ {"t2", "org-a", "Alpha"},
+ {"t3", "org-b", "Other org"},
+ } {
+ if err := r.CreateFrameTemplate(ctx, &store.FrameTemplate{
+ ID: spec.id, OrgID: spec.org, Title: spec.title, Description: "d",
+ Prefill: []byte("slots: {}\n"), FieldRules: []byte("{}"),
+ CreatedBy: "u", CreatedAt: now, UpdatedAt: now,
+ }); err != nil {
+ t.Fatalf("seed %s: %v", spec.id, err)
+ }
+ }
+ rows, err := r.ListFrameTemplatesByOrg(ctx, "org-a")
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(rows) != 2 {
+ t.Fatalf("got %d rows, want 2 (org-b's must not appear)", len(rows))
+ }
+ if rows[0].Title != "Alpha" || rows[1].Title != "Zebra" {
+ t.Errorf("order = %q, %q, want Alpha then Zebra", rows[0].Title, rows[1].Title)
+ }
+}
From 052a08ec2b7ee25dc2af798f9bdf18bb7bb7b974 Mon Sep 17 00:00:00 2001
From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com>
Date: Mon, 7 Sep 2026 12:35:58 +0200
Subject: [PATCH 07/43] feat(proto): add the Frame template RPCs and
template_id on publish
---
gen/go/frames/v1/frame.pb.go | 359 ++++++++-
gen/go/frames/v1/frame_service.pb.go | 747 ++++++++++++++++--
.../framesv1connect/frame_service.connect.go | 187 ++++-
gen/ts/frames/v1/frame_pb.ts | 157 +++-
gen/ts/frames/v1/frame_service_pb.ts | 273 ++++++-
proto/frames/v1/frame.proto | 45 ++
proto/frames/v1/frame_service.proto | 50 ++
7 files changed, 1690 insertions(+), 128 deletions(-)
diff --git a/gen/go/frames/v1/frame.pb.go b/gen/go/frames/v1/frame.pb.go
index 0350643..5f72070 100644
--- a/gen/go/frames/v1/frame.pb.go
+++ b/gen/go/frames/v1/frame.pb.go
@@ -22,6 +22,61 @@ const (
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
+// Requirement is how strongly a template wants a slot filled. Stricter than the
+// Frame schema, which requires no slot at all: an org uses it to hold its own
+// Frames to a house standard the spec does not impose.
+type Requirement int32
+
+const (
+ Requirement_REQUIREMENT_UNSPECIFIED Requirement = 0 // treated as optional
+ Requirement_REQUIREMENT_OPTIONAL Requirement = 1
+ Requirement_REQUIREMENT_RECOMMENDED Requirement = 2
+ Requirement_REQUIREMENT_REQUIRED Requirement = 3
+)
+
+// Enum value maps for Requirement.
+var (
+ Requirement_name = map[int32]string{
+ 0: "REQUIREMENT_UNSPECIFIED",
+ 1: "REQUIREMENT_OPTIONAL",
+ 2: "REQUIREMENT_RECOMMENDED",
+ 3: "REQUIREMENT_REQUIRED",
+ }
+ Requirement_value = map[string]int32{
+ "REQUIREMENT_UNSPECIFIED": 0,
+ "REQUIREMENT_OPTIONAL": 1,
+ "REQUIREMENT_RECOMMENDED": 2,
+ "REQUIREMENT_REQUIRED": 3,
+ }
+)
+
+func (x Requirement) Enum() *Requirement {
+ p := new(Requirement)
+ *p = x
+ return p
+}
+
+func (x Requirement) String() string {
+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (Requirement) Descriptor() protoreflect.EnumDescriptor {
+ return file_frames_v1_frame_proto_enumTypes[0].Descriptor()
+}
+
+func (Requirement) Type() protoreflect.EnumType {
+ return &file_frames_v1_frame_proto_enumTypes[0]
+}
+
+func (x Requirement) Number() protoreflect.EnumNumber {
+ return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use Requirement.Descriptor instead.
+func (Requirement) EnumDescriptor() ([]byte, []int) {
+ return file_frames_v1_frame_proto_rawDescGZIP(), []int{0}
+}
+
type Org struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
@@ -622,6 +677,224 @@ func (x *FrameVersionSummary) GetPublishedAt() *timestamppb.Timestamp {
return nil
}
+// FieldRule is a template's expectation for one slot. `note` is the template
+// author's own guidance on what belongs there: the seeded authoring form shows
+// it in place of the generic per-slot hint, and an MCP client asks it as a
+// question. A message rather than a bare Requirement because widening a map's
+// value type later would be a breaking wire change.
+type FieldRule struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Level Requirement `protobuf:"varint,1,opt,name=level,proto3,enum=frames.v1.Requirement" json:"level,omitempty"`
+ Note string `protobuf:"bytes,2,opt,name=note,proto3" json:"note,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *FieldRule) Reset() {
+ *x = FieldRule{}
+ mi := &file_frames_v1_frame_proto_msgTypes[8]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *FieldRule) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FieldRule) ProtoMessage() {}
+
+func (x *FieldRule) ProtoReflect() protoreflect.Message {
+ mi := &file_frames_v1_frame_proto_msgTypes[8]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use FieldRule.ProtoReflect.Descriptor instead.
+func (*FieldRule) Descriptor() ([]byte, []int) {
+ return file_frames_v1_frame_proto_rawDescGZIP(), []int{8}
+}
+
+func (x *FieldRule) GetLevel() Requirement {
+ if x != nil {
+ return x.Level
+ }
+ return Requirement_REQUIREMENT_UNSPECIFIED
+}
+
+func (x *FieldRule) GetNote() string {
+ if x != nil {
+ return x.Note
+ }
+ return ""
+}
+
+// FrameTemplateSummary is what a picker needs: no prefill, so listing stays
+// cheap. `builtin` is computed server-side so no client has to parse an id.
+type FrameTemplateSummary struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
+ Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"`
+ Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"`
+ Builtin bool `protobuf:"varint,4,opt,name=builtin,proto3" json:"builtin,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *FrameTemplateSummary) Reset() {
+ *x = FrameTemplateSummary{}
+ mi := &file_frames_v1_frame_proto_msgTypes[9]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *FrameTemplateSummary) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FrameTemplateSummary) ProtoMessage() {}
+
+func (x *FrameTemplateSummary) ProtoReflect() protoreflect.Message {
+ mi := &file_frames_v1_frame_proto_msgTypes[9]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use FrameTemplateSummary.ProtoReflect.Descriptor instead.
+func (*FrameTemplateSummary) Descriptor() ([]byte, []int) {
+ return file_frames_v1_frame_proto_rawDescGZIP(), []int{9}
+}
+
+func (x *FrameTemplateSummary) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
+func (x *FrameTemplateSummary) GetTitle() string {
+ if x != nil {
+ return x.Title
+ }
+ return ""
+}
+
+func (x *FrameTemplateSummary) GetDescription() string {
+ if x != nil {
+ return x.Description
+ }
+ return ""
+}
+
+func (x *FrameTemplateSummary) GetBuiltin() bool {
+ if x != nil {
+ return x.Builtin
+ }
+ return false
+}
+
+type FrameTemplate struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
+ Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"`
+ Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"`
+ Builtin bool `protobuf:"varint,4,opt,name=builtin,proto3" json:"builtin,omitempty"`
+ // Canonical YAML carrying only `slots` and `extends`. Bytes rather than a
+ // structured message on purpose: a message mirroring the slot schema would be
+ // a second place to update whenever frames.SlotTable changes, which is what
+ // the reflective guards in the MCP tests exist to catch.
+ Prefill []byte `protobuf:"bytes,5,opt,name=prefill,proto3" json:"prefill,omitempty"`
+ // Slot key (terminology, rules, skills, prompts, tool_specs, goals, style,
+ // norms, architecture, business_process) -> rule. Keyed by string, so adding
+ // a slot does not change this shape either.
+ FieldRules map[string]*FieldRule `protobuf:"bytes,6,rep,name=field_rules,json=fieldRules,proto3" json:"field_rules,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *FrameTemplate) Reset() {
+ *x = FrameTemplate{}
+ mi := &file_frames_v1_frame_proto_msgTypes[10]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *FrameTemplate) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FrameTemplate) ProtoMessage() {}
+
+func (x *FrameTemplate) ProtoReflect() protoreflect.Message {
+ mi := &file_frames_v1_frame_proto_msgTypes[10]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use FrameTemplate.ProtoReflect.Descriptor instead.
+func (*FrameTemplate) Descriptor() ([]byte, []int) {
+ return file_frames_v1_frame_proto_rawDescGZIP(), []int{10}
+}
+
+func (x *FrameTemplate) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
+func (x *FrameTemplate) GetTitle() string {
+ if x != nil {
+ return x.Title
+ }
+ return ""
+}
+
+func (x *FrameTemplate) GetDescription() string {
+ if x != nil {
+ return x.Description
+ }
+ return ""
+}
+
+func (x *FrameTemplate) GetBuiltin() bool {
+ if x != nil {
+ return x.Builtin
+ }
+ return false
+}
+
+func (x *FrameTemplate) GetPrefill() []byte {
+ if x != nil {
+ return x.Prefill
+ }
+ return nil
+}
+
+func (x *FrameTemplate) GetFieldRules() map[string]*FieldRule {
+ if x != nil {
+ return x.FieldRules
+ }
+ return nil
+}
+
var File_frames_v1_frame_proto protoreflect.FileDescriptor
const file_frames_v1_frame_proto_rawDesc = "" +
@@ -680,7 +953,31 @@ const file_frames_v1_frame_proto_rawDesc = "" +
"\aversion\x18\x01 \x01(\tR\aversion\x12\x1c\n" +
"\tchangelog\x18\x02 \x01(\tR\tchangelog\x12!\n" +
"\fpublished_by\x18\x03 \x01(\tR\vpublishedBy\x12=\n" +
- "\fpublished_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\vpublishedAtB\x9f\x01\n" +
+ "\fpublished_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\vpublishedAt\"M\n" +
+ "\tFieldRule\x12,\n" +
+ "\x05level\x18\x01 \x01(\x0e2\x16.frames.v1.RequirementR\x05level\x12\x12\n" +
+ "\x04note\x18\x02 \x01(\tR\x04note\"x\n" +
+ "\x14FrameTemplateSummary\x12\x0e\n" +
+ "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" +
+ "\x05title\x18\x02 \x01(\tR\x05title\x12 \n" +
+ "\vdescription\x18\x03 \x01(\tR\vdescription\x12\x18\n" +
+ "\abuiltin\x18\x04 \x01(\bR\abuiltin\"\xab\x02\n" +
+ "\rFrameTemplate\x12\x0e\n" +
+ "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" +
+ "\x05title\x18\x02 \x01(\tR\x05title\x12 \n" +
+ "\vdescription\x18\x03 \x01(\tR\vdescription\x12\x18\n" +
+ "\abuiltin\x18\x04 \x01(\bR\abuiltin\x12\x18\n" +
+ "\aprefill\x18\x05 \x01(\fR\aprefill\x12I\n" +
+ "\vfield_rules\x18\x06 \x03(\v2(.frames.v1.FrameTemplate.FieldRulesEntryR\n" +
+ "fieldRules\x1aS\n" +
+ "\x0fFieldRulesEntry\x12\x10\n" +
+ "\x03key\x18\x01 \x01(\tR\x03key\x12*\n" +
+ "\x05value\x18\x02 \x01(\v2\x14.frames.v1.FieldRuleR\x05value:\x028\x01*{\n" +
+ "\vRequirement\x12\x1b\n" +
+ "\x17REQUIREMENT_UNSPECIFIED\x10\x00\x12\x18\n" +
+ "\x14REQUIREMENT_OPTIONAL\x10\x01\x12\x1b\n" +
+ "\x17REQUIREMENT_RECOMMENDED\x10\x02\x12\x18\n" +
+ "\x14REQUIREMENT_REQUIRED\x10\x03B\x9f\x01\n" +
"\rcom.frames.v1B\n" +
"FrameProtoP\x01Z=github.com/nebari-dev/nebari-frames/gen/go/frames/v1;framesv1\xa2\x02\x03FXX\xaa\x02\tFrames.V1\xca\x02\tFrames\\V1\xe2\x02\x15Frames\\V1\\GPBMetadata\xea\x02\n" +
"Frames::V1b\x06proto3"
@@ -697,32 +994,41 @@ func file_frames_v1_frame_proto_rawDescGZIP() []byte {
return file_frames_v1_frame_proto_rawDescData
}
-var file_frames_v1_frame_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
+var file_frames_v1_frame_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
+var file_frames_v1_frame_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
var file_frames_v1_frame_proto_goTypes = []any{
- (*Org)(nil), // 0: frames.v1.Org
- (*Membership)(nil), // 1: frames.v1.Membership
- (*Frame)(nil), // 2: frames.v1.Frame
- (*ParentRef)(nil), // 3: frames.v1.ParentRef
- (*FrameVersion)(nil), // 4: frames.v1.FrameVersion
- (*Permissions)(nil), // 5: frames.v1.Permissions
- (*FrameSummary)(nil), // 6: frames.v1.FrameSummary
- (*FrameVersionSummary)(nil), // 7: frames.v1.FrameVersionSummary
- (*timestamppb.Timestamp)(nil), // 8: google.protobuf.Timestamp
+ (Requirement)(0), // 0: frames.v1.Requirement
+ (*Org)(nil), // 1: frames.v1.Org
+ (*Membership)(nil), // 2: frames.v1.Membership
+ (*Frame)(nil), // 3: frames.v1.Frame
+ (*ParentRef)(nil), // 4: frames.v1.ParentRef
+ (*FrameVersion)(nil), // 5: frames.v1.FrameVersion
+ (*Permissions)(nil), // 6: frames.v1.Permissions
+ (*FrameSummary)(nil), // 7: frames.v1.FrameSummary
+ (*FrameVersionSummary)(nil), // 8: frames.v1.FrameVersionSummary
+ (*FieldRule)(nil), // 9: frames.v1.FieldRule
+ (*FrameTemplateSummary)(nil), // 10: frames.v1.FrameTemplateSummary
+ (*FrameTemplate)(nil), // 11: frames.v1.FrameTemplate
+ nil, // 12: frames.v1.FrameTemplate.FieldRulesEntry
+ (*timestamppb.Timestamp)(nil), // 13: google.protobuf.Timestamp
}
var file_frames_v1_frame_proto_depIdxs = []int32{
- 8, // 0: frames.v1.Org.created_at:type_name -> google.protobuf.Timestamp
- 8, // 1: frames.v1.Membership.added_at:type_name -> google.protobuf.Timestamp
- 8, // 2: frames.v1.Frame.created_at:type_name -> google.protobuf.Timestamp
- 8, // 3: frames.v1.Frame.updated_at:type_name -> google.protobuf.Timestamp
- 8, // 4: frames.v1.FrameVersion.published_at:type_name -> google.protobuf.Timestamp
- 8, // 5: frames.v1.FrameSummary.updated_at:type_name -> google.protobuf.Timestamp
- 5, // 6: frames.v1.FrameSummary.permissions:type_name -> frames.v1.Permissions
- 8, // 7: frames.v1.FrameVersionSummary.published_at:type_name -> google.protobuf.Timestamp
- 8, // [8:8] is the sub-list for method output_type
- 8, // [8:8] is the sub-list for method input_type
- 8, // [8:8] is the sub-list for extension type_name
- 8, // [8:8] is the sub-list for extension extendee
- 0, // [0:8] is the sub-list for field type_name
+ 13, // 0: frames.v1.Org.created_at:type_name -> google.protobuf.Timestamp
+ 13, // 1: frames.v1.Membership.added_at:type_name -> google.protobuf.Timestamp
+ 13, // 2: frames.v1.Frame.created_at:type_name -> google.protobuf.Timestamp
+ 13, // 3: frames.v1.Frame.updated_at:type_name -> google.protobuf.Timestamp
+ 13, // 4: frames.v1.FrameVersion.published_at:type_name -> google.protobuf.Timestamp
+ 13, // 5: frames.v1.FrameSummary.updated_at:type_name -> google.protobuf.Timestamp
+ 6, // 6: frames.v1.FrameSummary.permissions:type_name -> frames.v1.Permissions
+ 13, // 7: frames.v1.FrameVersionSummary.published_at:type_name -> google.protobuf.Timestamp
+ 0, // 8: frames.v1.FieldRule.level:type_name -> frames.v1.Requirement
+ 12, // 9: frames.v1.FrameTemplate.field_rules:type_name -> frames.v1.FrameTemplate.FieldRulesEntry
+ 9, // 10: frames.v1.FrameTemplate.FieldRulesEntry.value:type_name -> frames.v1.FieldRule
+ 11, // [11:11] is the sub-list for method output_type
+ 11, // [11:11] is the sub-list for method input_type
+ 11, // [11:11] is the sub-list for extension type_name
+ 11, // [11:11] is the sub-list for extension extendee
+ 0, // [0:11] is the sub-list for field type_name
}
func init() { file_frames_v1_frame_proto_init() }
@@ -735,13 +1041,14 @@ func file_frames_v1_frame_proto_init() {
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_frames_v1_frame_proto_rawDesc), len(file_frames_v1_frame_proto_rawDesc)),
- NumEnums: 0,
- NumMessages: 8,
+ NumEnums: 1,
+ NumMessages: 12,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_frames_v1_frame_proto_goTypes,
DependencyIndexes: file_frames_v1_frame_proto_depIdxs,
+ EnumInfos: file_frames_v1_frame_proto_enumTypes,
MessageInfos: file_frames_v1_frame_proto_msgTypes,
}.Build()
File_frames_v1_frame_proto = out.File
diff --git a/gen/go/frames/v1/frame_service.pb.go b/gen/go/frames/v1/frame_service.pb.go
index 4db8e32..8db4707 100644
--- a/gen/go/frames/v1/frame_service.pb.go
+++ b/gen/go/frames/v1/frame_service.pb.go
@@ -22,9 +22,14 @@ const (
)
type PublishFrameRequest struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- Content []byte `protobuf:"bytes,1,opt,name=content,proto3" json:"content,omitempty"` // full YAML; name/version/extends/excludes/slots parsed server-side
- Changelog string `protobuf:"bytes,2,opt,name=changelog,proto3" json:"changelog,omitempty"`
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Content []byte `protobuf:"bytes,1,opt,name=content,proto3" json:"content,omitempty"` // full YAML; name/version/extends/excludes/slots parsed server-side
+ Changelog string `protobuf:"bytes,2,opt,name=changelog,proto3" json:"changelog,omitempty"`
+ // Optional. Names the template this Frame is being created from, so its
+ // required slots are checked. Used for validation and then discarded:
+ // nothing is recorded on the Frame, and the check applies on create only.
+ // Empty means no template and no checks.
+ TemplateId string `protobuf:"bytes,3,opt,name=template_id,json=templateId,proto3" json:"template_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -73,6 +78,13 @@ func (x *PublishFrameRequest) GetChangelog() string {
return ""
}
+func (x *PublishFrameRequest) GetTemplateId() string {
+ if x != nil {
+ return x.TemplateId
+ }
+ return ""
+}
+
type PublishFrameResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Frame *Frame `protobuf:"bytes,1,opt,name=frame,proto3" json:"frame,omitempty"`
@@ -1412,14 +1424,506 @@ func (*RemoveOrgMemberResponse) Descriptor() ([]byte, []int) {
return file_frames_v1_frame_service_proto_rawDescGZIP(), []int{26}
}
+type ListFrameTemplatesRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ListFrameTemplatesRequest) Reset() {
+ *x = ListFrameTemplatesRequest{}
+ mi := &file_frames_v1_frame_service_proto_msgTypes[27]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ListFrameTemplatesRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ListFrameTemplatesRequest) ProtoMessage() {}
+
+func (x *ListFrameTemplatesRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_frames_v1_frame_service_proto_msgTypes[27]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ListFrameTemplatesRequest.ProtoReflect.Descriptor instead.
+func (*ListFrameTemplatesRequest) Descriptor() ([]byte, []int) {
+ return file_frames_v1_frame_service_proto_rawDescGZIP(), []int{27}
+}
+
+type ListFrameTemplatesResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Templates []*FrameTemplateSummary `protobuf:"bytes,1,rep,name=templates,proto3" json:"templates,omitempty"`
+ // Whether the caller may create, edit, and delete org templates, so the SPA
+ // does not have to infer admin-ness itself. Mirrors ListFramesResponse.can_create.
+ CanManage bool `protobuf:"varint,2,opt,name=can_manage,json=canManage,proto3" json:"can_manage,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ListFrameTemplatesResponse) Reset() {
+ *x = ListFrameTemplatesResponse{}
+ mi := &file_frames_v1_frame_service_proto_msgTypes[28]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ListFrameTemplatesResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ListFrameTemplatesResponse) ProtoMessage() {}
+
+func (x *ListFrameTemplatesResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_frames_v1_frame_service_proto_msgTypes[28]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ListFrameTemplatesResponse.ProtoReflect.Descriptor instead.
+func (*ListFrameTemplatesResponse) Descriptor() ([]byte, []int) {
+ return file_frames_v1_frame_service_proto_rawDescGZIP(), []int{28}
+}
+
+func (x *ListFrameTemplatesResponse) GetTemplates() []*FrameTemplateSummary {
+ if x != nil {
+ return x.Templates
+ }
+ return nil
+}
+
+func (x *ListFrameTemplatesResponse) GetCanManage() bool {
+ if x != nil {
+ return x.CanManage
+ }
+ return false
+}
+
+type GetFrameTemplateRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *GetFrameTemplateRequest) Reset() {
+ *x = GetFrameTemplateRequest{}
+ mi := &file_frames_v1_frame_service_proto_msgTypes[29]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *GetFrameTemplateRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GetFrameTemplateRequest) ProtoMessage() {}
+
+func (x *GetFrameTemplateRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_frames_v1_frame_service_proto_msgTypes[29]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use GetFrameTemplateRequest.ProtoReflect.Descriptor instead.
+func (*GetFrameTemplateRequest) Descriptor() ([]byte, []int) {
+ return file_frames_v1_frame_service_proto_rawDescGZIP(), []int{29}
+}
+
+func (x *GetFrameTemplateRequest) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
+type GetFrameTemplateResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Template *FrameTemplate `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *GetFrameTemplateResponse) Reset() {
+ *x = GetFrameTemplateResponse{}
+ mi := &file_frames_v1_frame_service_proto_msgTypes[30]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *GetFrameTemplateResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GetFrameTemplateResponse) ProtoMessage() {}
+
+func (x *GetFrameTemplateResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_frames_v1_frame_service_proto_msgTypes[30]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use GetFrameTemplateResponse.ProtoReflect.Descriptor instead.
+func (*GetFrameTemplateResponse) Descriptor() ([]byte, []int) {
+ return file_frames_v1_frame_service_proto_rawDescGZIP(), []int{30}
+}
+
+func (x *GetFrameTemplateResponse) GetTemplate() *FrameTemplate {
+ if x != nil {
+ return x.Template
+ }
+ return nil
+}
+
+type CreateFrameTemplateRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
+ Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"`
+ Prefill []byte `protobuf:"bytes,3,opt,name=prefill,proto3" json:"prefill,omitempty"`
+ FieldRules map[string]*FieldRule `protobuf:"bytes,4,rep,name=field_rules,json=fieldRules,proto3" json:"field_rules,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *CreateFrameTemplateRequest) Reset() {
+ *x = CreateFrameTemplateRequest{}
+ mi := &file_frames_v1_frame_service_proto_msgTypes[31]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *CreateFrameTemplateRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*CreateFrameTemplateRequest) ProtoMessage() {}
+
+func (x *CreateFrameTemplateRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_frames_v1_frame_service_proto_msgTypes[31]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use CreateFrameTemplateRequest.ProtoReflect.Descriptor instead.
+func (*CreateFrameTemplateRequest) Descriptor() ([]byte, []int) {
+ return file_frames_v1_frame_service_proto_rawDescGZIP(), []int{31}
+}
+
+func (x *CreateFrameTemplateRequest) GetTitle() string {
+ if x != nil {
+ return x.Title
+ }
+ return ""
+}
+
+func (x *CreateFrameTemplateRequest) GetDescription() string {
+ if x != nil {
+ return x.Description
+ }
+ return ""
+}
+
+func (x *CreateFrameTemplateRequest) GetPrefill() []byte {
+ if x != nil {
+ return x.Prefill
+ }
+ return nil
+}
+
+func (x *CreateFrameTemplateRequest) GetFieldRules() map[string]*FieldRule {
+ if x != nil {
+ return x.FieldRules
+ }
+ return nil
+}
+
+type CreateFrameTemplateResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Template *FrameTemplate `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *CreateFrameTemplateResponse) Reset() {
+ *x = CreateFrameTemplateResponse{}
+ mi := &file_frames_v1_frame_service_proto_msgTypes[32]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *CreateFrameTemplateResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*CreateFrameTemplateResponse) ProtoMessage() {}
+
+func (x *CreateFrameTemplateResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_frames_v1_frame_service_proto_msgTypes[32]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use CreateFrameTemplateResponse.ProtoReflect.Descriptor instead.
+func (*CreateFrameTemplateResponse) Descriptor() ([]byte, []int) {
+ return file_frames_v1_frame_service_proto_rawDescGZIP(), []int{32}
+}
+
+func (x *CreateFrameTemplateResponse) GetTemplate() *FrameTemplate {
+ if x != nil {
+ return x.Template
+ }
+ return nil
+}
+
+type UpdateFrameTemplateRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
+ Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"`
+ Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"`
+ Prefill []byte `protobuf:"bytes,4,opt,name=prefill,proto3" json:"prefill,omitempty"`
+ FieldRules map[string]*FieldRule `protobuf:"bytes,5,rep,name=field_rules,json=fieldRules,proto3" json:"field_rules,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *UpdateFrameTemplateRequest) Reset() {
+ *x = UpdateFrameTemplateRequest{}
+ mi := &file_frames_v1_frame_service_proto_msgTypes[33]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *UpdateFrameTemplateRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UpdateFrameTemplateRequest) ProtoMessage() {}
+
+func (x *UpdateFrameTemplateRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_frames_v1_frame_service_proto_msgTypes[33]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use UpdateFrameTemplateRequest.ProtoReflect.Descriptor instead.
+func (*UpdateFrameTemplateRequest) Descriptor() ([]byte, []int) {
+ return file_frames_v1_frame_service_proto_rawDescGZIP(), []int{33}
+}
+
+func (x *UpdateFrameTemplateRequest) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
+func (x *UpdateFrameTemplateRequest) GetTitle() string {
+ if x != nil {
+ return x.Title
+ }
+ return ""
+}
+
+func (x *UpdateFrameTemplateRequest) GetDescription() string {
+ if x != nil {
+ return x.Description
+ }
+ return ""
+}
+
+func (x *UpdateFrameTemplateRequest) GetPrefill() []byte {
+ if x != nil {
+ return x.Prefill
+ }
+ return nil
+}
+
+func (x *UpdateFrameTemplateRequest) GetFieldRules() map[string]*FieldRule {
+ if x != nil {
+ return x.FieldRules
+ }
+ return nil
+}
+
+type UpdateFrameTemplateResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Template *FrameTemplate `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *UpdateFrameTemplateResponse) Reset() {
+ *x = UpdateFrameTemplateResponse{}
+ mi := &file_frames_v1_frame_service_proto_msgTypes[34]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *UpdateFrameTemplateResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UpdateFrameTemplateResponse) ProtoMessage() {}
+
+func (x *UpdateFrameTemplateResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_frames_v1_frame_service_proto_msgTypes[34]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use UpdateFrameTemplateResponse.ProtoReflect.Descriptor instead.
+func (*UpdateFrameTemplateResponse) Descriptor() ([]byte, []int) {
+ return file_frames_v1_frame_service_proto_rawDescGZIP(), []int{34}
+}
+
+func (x *UpdateFrameTemplateResponse) GetTemplate() *FrameTemplate {
+ if x != nil {
+ return x.Template
+ }
+ return nil
+}
+
+type DeleteFrameTemplateRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *DeleteFrameTemplateRequest) Reset() {
+ *x = DeleteFrameTemplateRequest{}
+ mi := &file_frames_v1_frame_service_proto_msgTypes[35]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *DeleteFrameTemplateRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*DeleteFrameTemplateRequest) ProtoMessage() {}
+
+func (x *DeleteFrameTemplateRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_frames_v1_frame_service_proto_msgTypes[35]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use DeleteFrameTemplateRequest.ProtoReflect.Descriptor instead.
+func (*DeleteFrameTemplateRequest) Descriptor() ([]byte, []int) {
+ return file_frames_v1_frame_service_proto_rawDescGZIP(), []int{35}
+}
+
+func (x *DeleteFrameTemplateRequest) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
+type DeleteFrameTemplateResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *DeleteFrameTemplateResponse) Reset() {
+ *x = DeleteFrameTemplateResponse{}
+ mi := &file_frames_v1_frame_service_proto_msgTypes[36]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *DeleteFrameTemplateResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*DeleteFrameTemplateResponse) ProtoMessage() {}
+
+func (x *DeleteFrameTemplateResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_frames_v1_frame_service_proto_msgTypes[36]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use DeleteFrameTemplateResponse.ProtoReflect.Descriptor instead.
+func (*DeleteFrameTemplateResponse) Descriptor() ([]byte, []int) {
+ return file_frames_v1_frame_service_proto_rawDescGZIP(), []int{36}
+}
+
var File_frames_v1_frame_service_proto protoreflect.FileDescriptor
const file_frames_v1_frame_service_proto_rawDesc = "" +
"\n" +
- "\x1dframes/v1/frame_service.proto\x12\tframes.v1\x1a\x15frames/v1/frame.proto\"M\n" +
+ "\x1dframes/v1/frame_service.proto\x12\tframes.v1\x1a\x15frames/v1/frame.proto\"n\n" +
"\x13PublishFrameRequest\x12\x18\n" +
"\acontent\x18\x01 \x01(\fR\acontent\x12\x1c\n" +
- "\tchangelog\x18\x02 \x01(\tR\tchangelog\"q\n" +
+ "\tchangelog\x18\x02 \x01(\tR\tchangelog\x12\x1f\n" +
+ "\vtemplate_id\x18\x03 \x01(\tR\n" +
+ "templateId\"q\n" +
"\x14PublishFrameResponse\x12&\n" +
"\x05frame\x18\x01 \x01(\v2\x10.frames.v1.FrameR\x05frame\x121\n" +
"\aversion\x18\x02 \x01(\v2\x17.frames.v1.FrameVersionR\aversion\"\x13\n" +
@@ -1495,7 +1999,42 @@ const file_frames_v1_frame_service_proto_rawDesc = "" +
"\x16RemoveOrgMemberRequest\x12\x19\n" +
"\buser_sub\x18\x01 \x01(\tR\auserSub\x12\x14\n" +
"\x05email\x18\x02 \x01(\tR\x05email\"\x19\n" +
- "\x17RemoveOrgMemberResponse2\xd1\a\n" +
+ "\x17RemoveOrgMemberResponse\"\x1b\n" +
+ "\x19ListFrameTemplatesRequest\"z\n" +
+ "\x1aListFrameTemplatesResponse\x12=\n" +
+ "\ttemplates\x18\x01 \x03(\v2\x1f.frames.v1.FrameTemplateSummaryR\ttemplates\x12\x1d\n" +
+ "\n" +
+ "can_manage\x18\x02 \x01(\bR\tcanManage\")\n" +
+ "\x17GetFrameTemplateRequest\x12\x0e\n" +
+ "\x02id\x18\x01 \x01(\tR\x02id\"P\n" +
+ "\x18GetFrameTemplateResponse\x124\n" +
+ "\btemplate\x18\x01 \x01(\v2\x18.frames.v1.FrameTemplateR\btemplate\"\x9b\x02\n" +
+ "\x1aCreateFrameTemplateRequest\x12\x14\n" +
+ "\x05title\x18\x01 \x01(\tR\x05title\x12 \n" +
+ "\vdescription\x18\x02 \x01(\tR\vdescription\x12\x18\n" +
+ "\aprefill\x18\x03 \x01(\fR\aprefill\x12V\n" +
+ "\vfield_rules\x18\x04 \x03(\v25.frames.v1.CreateFrameTemplateRequest.FieldRulesEntryR\n" +
+ "fieldRules\x1aS\n" +
+ "\x0fFieldRulesEntry\x12\x10\n" +
+ "\x03key\x18\x01 \x01(\tR\x03key\x12*\n" +
+ "\x05value\x18\x02 \x01(\v2\x14.frames.v1.FieldRuleR\x05value:\x028\x01\"S\n" +
+ "\x1bCreateFrameTemplateResponse\x124\n" +
+ "\btemplate\x18\x01 \x01(\v2\x18.frames.v1.FrameTemplateR\btemplate\"\xab\x02\n" +
+ "\x1aUpdateFrameTemplateRequest\x12\x0e\n" +
+ "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" +
+ "\x05title\x18\x02 \x01(\tR\x05title\x12 \n" +
+ "\vdescription\x18\x03 \x01(\tR\vdescription\x12\x18\n" +
+ "\aprefill\x18\x04 \x01(\fR\aprefill\x12V\n" +
+ "\vfield_rules\x18\x05 \x03(\v25.frames.v1.UpdateFrameTemplateRequest.FieldRulesEntryR\n" +
+ "fieldRules\x1aS\n" +
+ "\x0fFieldRulesEntry\x12\x10\n" +
+ "\x03key\x18\x01 \x01(\tR\x03key\x12*\n" +
+ "\x05value\x18\x02 \x01(\v2\x14.frames.v1.FieldRuleR\x05value:\x028\x01\"S\n" +
+ "\x1bUpdateFrameTemplateResponse\x124\n" +
+ "\btemplate\x18\x01 \x01(\v2\x18.frames.v1.FrameTemplateR\btemplate\",\n" +
+ "\x1aDeleteFrameTemplateRequest\x12\x0e\n" +
+ "\x02id\x18\x01 \x01(\tR\x02id\"\x1d\n" +
+ "\x1bDeleteFrameTemplateResponse2\xc3\v\n" +
"\fFrameService\x12O\n" +
"\fPublishFrame\x12\x1e.frames.v1.PublishFrameRequest\x1a\x1f.frames.v1.PublishFrameResponse\x12I\n" +
"\n" +
@@ -1505,7 +2044,12 @@ const file_frames_v1_frame_service_proto_rawDesc = "" +
"\x05GetMe\x12\x17.frames.v1.GetMeRequest\x1a\x18.frames.v1.GetMeResponse\x12^\n" +
"\x11ListFrameVersions\x12#.frames.v1.ListFrameVersionsRequest\x1a$.frames.v1.ListFrameVersionsResponse\x12L\n" +
"\vDeleteFrame\x12\x1d.frames.v1.DeleteFrameRequest\x1a\x1e.frames.v1.DeleteFrameResponse\x12O\n" +
- "\fConvertFrame\x12\x1e.frames.v1.ConvertFrameRequest\x1a\x1f.frames.v1.ConvertFrameResponse\x12U\n" +
+ "\fConvertFrame\x12\x1e.frames.v1.ConvertFrameRequest\x1a\x1f.frames.v1.ConvertFrameResponse\x12a\n" +
+ "\x12ListFrameTemplates\x12$.frames.v1.ListFrameTemplatesRequest\x1a%.frames.v1.ListFrameTemplatesResponse\x12[\n" +
+ "\x10GetFrameTemplate\x12\".frames.v1.GetFrameTemplateRequest\x1a#.frames.v1.GetFrameTemplateResponse\x12d\n" +
+ "\x13CreateFrameTemplate\x12%.frames.v1.CreateFrameTemplateRequest\x1a&.frames.v1.CreateFrameTemplateResponse\x12d\n" +
+ "\x13UpdateFrameTemplate\x12%.frames.v1.UpdateFrameTemplateRequest\x1a&.frames.v1.UpdateFrameTemplateResponse\x12d\n" +
+ "\x13DeleteFrameTemplate\x12%.frames.v1.DeleteFrameTemplateRequest\x1a&.frames.v1.DeleteFrameTemplateResponse\x12U\n" +
"\x0eListOrgMembers\x12 .frames.v1.ListOrgMembersRequest\x1a!.frames.v1.ListOrgMembersResponse\x12O\n" +
"\fAddOrgMember\x12\x1e.frames.v1.AddOrgMemberRequest\x1a\x1f.frames.v1.AddOrgMemberResponse\x12R\n" +
"\rSetMemberRole\x12\x1f.frames.v1.SetMemberRoleRequest\x1a .frames.v1.SetMemberRoleResponse\x12X\n" +
@@ -1525,87 +2069,120 @@ func file_frames_v1_frame_service_proto_rawDescGZIP() []byte {
return file_frames_v1_frame_service_proto_rawDescData
}
-var file_frames_v1_frame_service_proto_msgTypes = make([]protoimpl.MessageInfo, 27)
+var file_frames_v1_frame_service_proto_msgTypes = make([]protoimpl.MessageInfo, 39)
var file_frames_v1_frame_service_proto_goTypes = []any{
- (*PublishFrameRequest)(nil), // 0: frames.v1.PublishFrameRequest
- (*PublishFrameResponse)(nil), // 1: frames.v1.PublishFrameResponse
- (*ListFramesRequest)(nil), // 2: frames.v1.ListFramesRequest
- (*ListFramesResponse)(nil), // 3: frames.v1.ListFramesResponse
- (*GetFrameRequest)(nil), // 4: frames.v1.GetFrameRequest
- (*GetFrameResponse)(nil), // 5: frames.v1.GetFrameResponse
- (*ResolveFrameRequest)(nil), // 6: frames.v1.ResolveFrameRequest
- (*ResolveFrameResponse)(nil), // 7: frames.v1.ResolveFrameResponse
- (*GetMeRequest)(nil), // 8: frames.v1.GetMeRequest
- (*GetMeResponse)(nil), // 9: frames.v1.GetMeResponse
- (*ListFrameVersionsRequest)(nil), // 10: frames.v1.ListFrameVersionsRequest
- (*ListFrameVersionsResponse)(nil), // 11: frames.v1.ListFrameVersionsResponse
- (*FieldViolation)(nil), // 12: frames.v1.FieldViolation
- (*FieldViolations)(nil), // 13: frames.v1.FieldViolations
- (*ConvertFrameRequest)(nil), // 14: frames.v1.ConvertFrameRequest
- (*ConvertFrameResponse)(nil), // 15: frames.v1.ConvertFrameResponse
- (*DeleteFrameRequest)(nil), // 16: frames.v1.DeleteFrameRequest
- (*DeleteFrameResponse)(nil), // 17: frames.v1.DeleteFrameResponse
- (*DeleteBlocked)(nil), // 18: frames.v1.DeleteBlocked
- (*ListOrgMembersRequest)(nil), // 19: frames.v1.ListOrgMembersRequest
- (*ListOrgMembersResponse)(nil), // 20: frames.v1.ListOrgMembersResponse
- (*AddOrgMemberRequest)(nil), // 21: frames.v1.AddOrgMemberRequest
- (*AddOrgMemberResponse)(nil), // 22: frames.v1.AddOrgMemberResponse
- (*SetMemberRoleRequest)(nil), // 23: frames.v1.SetMemberRoleRequest
- (*SetMemberRoleResponse)(nil), // 24: frames.v1.SetMemberRoleResponse
- (*RemoveOrgMemberRequest)(nil), // 25: frames.v1.RemoveOrgMemberRequest
- (*RemoveOrgMemberResponse)(nil), // 26: frames.v1.RemoveOrgMemberResponse
- (*Frame)(nil), // 27: frames.v1.Frame
- (*FrameVersion)(nil), // 28: frames.v1.FrameVersion
- (*FrameSummary)(nil), // 29: frames.v1.FrameSummary
- (*ParentRef)(nil), // 30: frames.v1.ParentRef
- (*Permissions)(nil), // 31: frames.v1.Permissions
- (*Org)(nil), // 32: frames.v1.Org
- (*FrameVersionSummary)(nil), // 33: frames.v1.FrameVersionSummary
- (*Membership)(nil), // 34: frames.v1.Membership
+ (*PublishFrameRequest)(nil), // 0: frames.v1.PublishFrameRequest
+ (*PublishFrameResponse)(nil), // 1: frames.v1.PublishFrameResponse
+ (*ListFramesRequest)(nil), // 2: frames.v1.ListFramesRequest
+ (*ListFramesResponse)(nil), // 3: frames.v1.ListFramesResponse
+ (*GetFrameRequest)(nil), // 4: frames.v1.GetFrameRequest
+ (*GetFrameResponse)(nil), // 5: frames.v1.GetFrameResponse
+ (*ResolveFrameRequest)(nil), // 6: frames.v1.ResolveFrameRequest
+ (*ResolveFrameResponse)(nil), // 7: frames.v1.ResolveFrameResponse
+ (*GetMeRequest)(nil), // 8: frames.v1.GetMeRequest
+ (*GetMeResponse)(nil), // 9: frames.v1.GetMeResponse
+ (*ListFrameVersionsRequest)(nil), // 10: frames.v1.ListFrameVersionsRequest
+ (*ListFrameVersionsResponse)(nil), // 11: frames.v1.ListFrameVersionsResponse
+ (*FieldViolation)(nil), // 12: frames.v1.FieldViolation
+ (*FieldViolations)(nil), // 13: frames.v1.FieldViolations
+ (*ConvertFrameRequest)(nil), // 14: frames.v1.ConvertFrameRequest
+ (*ConvertFrameResponse)(nil), // 15: frames.v1.ConvertFrameResponse
+ (*DeleteFrameRequest)(nil), // 16: frames.v1.DeleteFrameRequest
+ (*DeleteFrameResponse)(nil), // 17: frames.v1.DeleteFrameResponse
+ (*DeleteBlocked)(nil), // 18: frames.v1.DeleteBlocked
+ (*ListOrgMembersRequest)(nil), // 19: frames.v1.ListOrgMembersRequest
+ (*ListOrgMembersResponse)(nil), // 20: frames.v1.ListOrgMembersResponse
+ (*AddOrgMemberRequest)(nil), // 21: frames.v1.AddOrgMemberRequest
+ (*AddOrgMemberResponse)(nil), // 22: frames.v1.AddOrgMemberResponse
+ (*SetMemberRoleRequest)(nil), // 23: frames.v1.SetMemberRoleRequest
+ (*SetMemberRoleResponse)(nil), // 24: frames.v1.SetMemberRoleResponse
+ (*RemoveOrgMemberRequest)(nil), // 25: frames.v1.RemoveOrgMemberRequest
+ (*RemoveOrgMemberResponse)(nil), // 26: frames.v1.RemoveOrgMemberResponse
+ (*ListFrameTemplatesRequest)(nil), // 27: frames.v1.ListFrameTemplatesRequest
+ (*ListFrameTemplatesResponse)(nil), // 28: frames.v1.ListFrameTemplatesResponse
+ (*GetFrameTemplateRequest)(nil), // 29: frames.v1.GetFrameTemplateRequest
+ (*GetFrameTemplateResponse)(nil), // 30: frames.v1.GetFrameTemplateResponse
+ (*CreateFrameTemplateRequest)(nil), // 31: frames.v1.CreateFrameTemplateRequest
+ (*CreateFrameTemplateResponse)(nil), // 32: frames.v1.CreateFrameTemplateResponse
+ (*UpdateFrameTemplateRequest)(nil), // 33: frames.v1.UpdateFrameTemplateRequest
+ (*UpdateFrameTemplateResponse)(nil), // 34: frames.v1.UpdateFrameTemplateResponse
+ (*DeleteFrameTemplateRequest)(nil), // 35: frames.v1.DeleteFrameTemplateRequest
+ (*DeleteFrameTemplateResponse)(nil), // 36: frames.v1.DeleteFrameTemplateResponse
+ nil, // 37: frames.v1.CreateFrameTemplateRequest.FieldRulesEntry
+ nil, // 38: frames.v1.UpdateFrameTemplateRequest.FieldRulesEntry
+ (*Frame)(nil), // 39: frames.v1.Frame
+ (*FrameVersion)(nil), // 40: frames.v1.FrameVersion
+ (*FrameSummary)(nil), // 41: frames.v1.FrameSummary
+ (*ParentRef)(nil), // 42: frames.v1.ParentRef
+ (*Permissions)(nil), // 43: frames.v1.Permissions
+ (*Org)(nil), // 44: frames.v1.Org
+ (*FrameVersionSummary)(nil), // 45: frames.v1.FrameVersionSummary
+ (*Membership)(nil), // 46: frames.v1.Membership
+ (*FrameTemplateSummary)(nil), // 47: frames.v1.FrameTemplateSummary
+ (*FrameTemplate)(nil), // 48: frames.v1.FrameTemplate
+ (*FieldRule)(nil), // 49: frames.v1.FieldRule
}
var file_frames_v1_frame_service_proto_depIdxs = []int32{
- 27, // 0: frames.v1.PublishFrameResponse.frame:type_name -> frames.v1.Frame
- 28, // 1: frames.v1.PublishFrameResponse.version:type_name -> frames.v1.FrameVersion
- 29, // 2: frames.v1.ListFramesResponse.frames:type_name -> frames.v1.FrameSummary
- 27, // 3: frames.v1.GetFrameResponse.frame:type_name -> frames.v1.Frame
- 28, // 4: frames.v1.GetFrameResponse.version:type_name -> frames.v1.FrameVersion
- 30, // 5: frames.v1.GetFrameResponse.extends:type_name -> frames.v1.ParentRef
- 31, // 6: frames.v1.GetFrameResponse.permissions:type_name -> frames.v1.Permissions
- 32, // 7: frames.v1.GetMeResponse.org:type_name -> frames.v1.Org
- 33, // 8: frames.v1.ListFrameVersionsResponse.versions:type_name -> frames.v1.FrameVersionSummary
+ 39, // 0: frames.v1.PublishFrameResponse.frame:type_name -> frames.v1.Frame
+ 40, // 1: frames.v1.PublishFrameResponse.version:type_name -> frames.v1.FrameVersion
+ 41, // 2: frames.v1.ListFramesResponse.frames:type_name -> frames.v1.FrameSummary
+ 39, // 3: frames.v1.GetFrameResponse.frame:type_name -> frames.v1.Frame
+ 40, // 4: frames.v1.GetFrameResponse.version:type_name -> frames.v1.FrameVersion
+ 42, // 5: frames.v1.GetFrameResponse.extends:type_name -> frames.v1.ParentRef
+ 43, // 6: frames.v1.GetFrameResponse.permissions:type_name -> frames.v1.Permissions
+ 44, // 7: frames.v1.GetMeResponse.org:type_name -> frames.v1.Org
+ 45, // 8: frames.v1.ListFrameVersionsResponse.versions:type_name -> frames.v1.FrameVersionSummary
12, // 9: frames.v1.FieldViolations.violations:type_name -> frames.v1.FieldViolation
- 34, // 10: frames.v1.ListOrgMembersResponse.members:type_name -> frames.v1.Membership
- 34, // 11: frames.v1.AddOrgMemberResponse.member:type_name -> frames.v1.Membership
- 34, // 12: frames.v1.SetMemberRoleResponse.member:type_name -> frames.v1.Membership
- 0, // 13: frames.v1.FrameService.PublishFrame:input_type -> frames.v1.PublishFrameRequest
- 2, // 14: frames.v1.FrameService.ListFrames:input_type -> frames.v1.ListFramesRequest
- 4, // 15: frames.v1.FrameService.GetFrame:input_type -> frames.v1.GetFrameRequest
- 6, // 16: frames.v1.FrameService.ResolveFrame:input_type -> frames.v1.ResolveFrameRequest
- 8, // 17: frames.v1.FrameService.GetMe:input_type -> frames.v1.GetMeRequest
- 10, // 18: frames.v1.FrameService.ListFrameVersions:input_type -> frames.v1.ListFrameVersionsRequest
- 16, // 19: frames.v1.FrameService.DeleteFrame:input_type -> frames.v1.DeleteFrameRequest
- 14, // 20: frames.v1.FrameService.ConvertFrame:input_type -> frames.v1.ConvertFrameRequest
- 19, // 21: frames.v1.FrameService.ListOrgMembers:input_type -> frames.v1.ListOrgMembersRequest
- 21, // 22: frames.v1.FrameService.AddOrgMember:input_type -> frames.v1.AddOrgMemberRequest
- 23, // 23: frames.v1.FrameService.SetMemberRole:input_type -> frames.v1.SetMemberRoleRequest
- 25, // 24: frames.v1.FrameService.RemoveOrgMember:input_type -> frames.v1.RemoveOrgMemberRequest
- 1, // 25: frames.v1.FrameService.PublishFrame:output_type -> frames.v1.PublishFrameResponse
- 3, // 26: frames.v1.FrameService.ListFrames:output_type -> frames.v1.ListFramesResponse
- 5, // 27: frames.v1.FrameService.GetFrame:output_type -> frames.v1.GetFrameResponse
- 7, // 28: frames.v1.FrameService.ResolveFrame:output_type -> frames.v1.ResolveFrameResponse
- 9, // 29: frames.v1.FrameService.GetMe:output_type -> frames.v1.GetMeResponse
- 11, // 30: frames.v1.FrameService.ListFrameVersions:output_type -> frames.v1.ListFrameVersionsResponse
- 17, // 31: frames.v1.FrameService.DeleteFrame:output_type -> frames.v1.DeleteFrameResponse
- 15, // 32: frames.v1.FrameService.ConvertFrame:output_type -> frames.v1.ConvertFrameResponse
- 20, // 33: frames.v1.FrameService.ListOrgMembers:output_type -> frames.v1.ListOrgMembersResponse
- 22, // 34: frames.v1.FrameService.AddOrgMember:output_type -> frames.v1.AddOrgMemberResponse
- 24, // 35: frames.v1.FrameService.SetMemberRole:output_type -> frames.v1.SetMemberRoleResponse
- 26, // 36: frames.v1.FrameService.RemoveOrgMember:output_type -> frames.v1.RemoveOrgMemberResponse
- 25, // [25:37] is the sub-list for method output_type
- 13, // [13:25] is the sub-list for method input_type
- 13, // [13:13] is the sub-list for extension type_name
- 13, // [13:13] is the sub-list for extension extendee
- 0, // [0:13] is the sub-list for field type_name
+ 46, // 10: frames.v1.ListOrgMembersResponse.members:type_name -> frames.v1.Membership
+ 46, // 11: frames.v1.AddOrgMemberResponse.member:type_name -> frames.v1.Membership
+ 46, // 12: frames.v1.SetMemberRoleResponse.member:type_name -> frames.v1.Membership
+ 47, // 13: frames.v1.ListFrameTemplatesResponse.templates:type_name -> frames.v1.FrameTemplateSummary
+ 48, // 14: frames.v1.GetFrameTemplateResponse.template:type_name -> frames.v1.FrameTemplate
+ 37, // 15: frames.v1.CreateFrameTemplateRequest.field_rules:type_name -> frames.v1.CreateFrameTemplateRequest.FieldRulesEntry
+ 48, // 16: frames.v1.CreateFrameTemplateResponse.template:type_name -> frames.v1.FrameTemplate
+ 38, // 17: frames.v1.UpdateFrameTemplateRequest.field_rules:type_name -> frames.v1.UpdateFrameTemplateRequest.FieldRulesEntry
+ 48, // 18: frames.v1.UpdateFrameTemplateResponse.template:type_name -> frames.v1.FrameTemplate
+ 49, // 19: frames.v1.CreateFrameTemplateRequest.FieldRulesEntry.value:type_name -> frames.v1.FieldRule
+ 49, // 20: frames.v1.UpdateFrameTemplateRequest.FieldRulesEntry.value:type_name -> frames.v1.FieldRule
+ 0, // 21: frames.v1.FrameService.PublishFrame:input_type -> frames.v1.PublishFrameRequest
+ 2, // 22: frames.v1.FrameService.ListFrames:input_type -> frames.v1.ListFramesRequest
+ 4, // 23: frames.v1.FrameService.GetFrame:input_type -> frames.v1.GetFrameRequest
+ 6, // 24: frames.v1.FrameService.ResolveFrame:input_type -> frames.v1.ResolveFrameRequest
+ 8, // 25: frames.v1.FrameService.GetMe:input_type -> frames.v1.GetMeRequest
+ 10, // 26: frames.v1.FrameService.ListFrameVersions:input_type -> frames.v1.ListFrameVersionsRequest
+ 16, // 27: frames.v1.FrameService.DeleteFrame:input_type -> frames.v1.DeleteFrameRequest
+ 14, // 28: frames.v1.FrameService.ConvertFrame:input_type -> frames.v1.ConvertFrameRequest
+ 27, // 29: frames.v1.FrameService.ListFrameTemplates:input_type -> frames.v1.ListFrameTemplatesRequest
+ 29, // 30: frames.v1.FrameService.GetFrameTemplate:input_type -> frames.v1.GetFrameTemplateRequest
+ 31, // 31: frames.v1.FrameService.CreateFrameTemplate:input_type -> frames.v1.CreateFrameTemplateRequest
+ 33, // 32: frames.v1.FrameService.UpdateFrameTemplate:input_type -> frames.v1.UpdateFrameTemplateRequest
+ 35, // 33: frames.v1.FrameService.DeleteFrameTemplate:input_type -> frames.v1.DeleteFrameTemplateRequest
+ 19, // 34: frames.v1.FrameService.ListOrgMembers:input_type -> frames.v1.ListOrgMembersRequest
+ 21, // 35: frames.v1.FrameService.AddOrgMember:input_type -> frames.v1.AddOrgMemberRequest
+ 23, // 36: frames.v1.FrameService.SetMemberRole:input_type -> frames.v1.SetMemberRoleRequest
+ 25, // 37: frames.v1.FrameService.RemoveOrgMember:input_type -> frames.v1.RemoveOrgMemberRequest
+ 1, // 38: frames.v1.FrameService.PublishFrame:output_type -> frames.v1.PublishFrameResponse
+ 3, // 39: frames.v1.FrameService.ListFrames:output_type -> frames.v1.ListFramesResponse
+ 5, // 40: frames.v1.FrameService.GetFrame:output_type -> frames.v1.GetFrameResponse
+ 7, // 41: frames.v1.FrameService.ResolveFrame:output_type -> frames.v1.ResolveFrameResponse
+ 9, // 42: frames.v1.FrameService.GetMe:output_type -> frames.v1.GetMeResponse
+ 11, // 43: frames.v1.FrameService.ListFrameVersions:output_type -> frames.v1.ListFrameVersionsResponse
+ 17, // 44: frames.v1.FrameService.DeleteFrame:output_type -> frames.v1.DeleteFrameResponse
+ 15, // 45: frames.v1.FrameService.ConvertFrame:output_type -> frames.v1.ConvertFrameResponse
+ 28, // 46: frames.v1.FrameService.ListFrameTemplates:output_type -> frames.v1.ListFrameTemplatesResponse
+ 30, // 47: frames.v1.FrameService.GetFrameTemplate:output_type -> frames.v1.GetFrameTemplateResponse
+ 32, // 48: frames.v1.FrameService.CreateFrameTemplate:output_type -> frames.v1.CreateFrameTemplateResponse
+ 34, // 49: frames.v1.FrameService.UpdateFrameTemplate:output_type -> frames.v1.UpdateFrameTemplateResponse
+ 36, // 50: frames.v1.FrameService.DeleteFrameTemplate:output_type -> frames.v1.DeleteFrameTemplateResponse
+ 20, // 51: frames.v1.FrameService.ListOrgMembers:output_type -> frames.v1.ListOrgMembersResponse
+ 22, // 52: frames.v1.FrameService.AddOrgMember:output_type -> frames.v1.AddOrgMemberResponse
+ 24, // 53: frames.v1.FrameService.SetMemberRole:output_type -> frames.v1.SetMemberRoleResponse
+ 26, // 54: frames.v1.FrameService.RemoveOrgMember:output_type -> frames.v1.RemoveOrgMemberResponse
+ 38, // [38:55] is the sub-list for method output_type
+ 21, // [21:38] is the sub-list for method input_type
+ 21, // [21:21] is the sub-list for extension type_name
+ 21, // [21:21] is the sub-list for extension extendee
+ 0, // [0:21] is the sub-list for field type_name
}
func init() { file_frames_v1_frame_service_proto_init() }
@@ -1624,7 +2201,7 @@ func file_frames_v1_frame_service_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_frames_v1_frame_service_proto_rawDesc), len(file_frames_v1_frame_service_proto_rawDesc)),
NumEnums: 0,
- NumMessages: 27,
+ NumMessages: 39,
NumExtensions: 0,
NumServices: 1,
},
diff --git a/gen/go/frames/v1/framesv1connect/frame_service.connect.go b/gen/go/frames/v1/framesv1connect/frame_service.connect.go
index 4c4c754..9fb6bfd 100644
--- a/gen/go/frames/v1/framesv1connect/frame_service.connect.go
+++ b/gen/go/frames/v1/framesv1connect/frame_service.connect.go
@@ -54,6 +54,21 @@ const (
// FrameServiceConvertFrameProcedure is the fully-qualified name of the FrameService's ConvertFrame
// RPC.
FrameServiceConvertFrameProcedure = "/frames.v1.FrameService/ConvertFrame"
+ // FrameServiceListFrameTemplatesProcedure is the fully-qualified name of the FrameService's
+ // ListFrameTemplates RPC.
+ FrameServiceListFrameTemplatesProcedure = "/frames.v1.FrameService/ListFrameTemplates"
+ // FrameServiceGetFrameTemplateProcedure is the fully-qualified name of the FrameService's
+ // GetFrameTemplate RPC.
+ FrameServiceGetFrameTemplateProcedure = "/frames.v1.FrameService/GetFrameTemplate"
+ // FrameServiceCreateFrameTemplateProcedure is the fully-qualified name of the FrameService's
+ // CreateFrameTemplate RPC.
+ FrameServiceCreateFrameTemplateProcedure = "/frames.v1.FrameService/CreateFrameTemplate"
+ // FrameServiceUpdateFrameTemplateProcedure is the fully-qualified name of the FrameService's
+ // UpdateFrameTemplate RPC.
+ FrameServiceUpdateFrameTemplateProcedure = "/frames.v1.FrameService/UpdateFrameTemplate"
+ // FrameServiceDeleteFrameTemplateProcedure is the fully-qualified name of the FrameService's
+ // DeleteFrameTemplate RPC.
+ FrameServiceDeleteFrameTemplateProcedure = "/frames.v1.FrameService/DeleteFrameTemplate"
// FrameServiceListOrgMembersProcedure is the fully-qualified name of the FrameService's
// ListOrgMembers RPC.
FrameServiceListOrgMembersProcedure = "/frames.v1.FrameService/ListOrgMembers"
@@ -87,6 +102,20 @@ type FrameServiceClient interface {
// Pure conversion between the canonical slot YAML and the spec-conformant
// .frame.md form. Stateless and unauthenticated beyond org membership.
ConvertFrame(context.Context, *connect.Request[v1.ConvertFrameRequest]) (*connect.Response[v1.ConvertFrameResponse], error)
+ // Read - the templates the caller may start a Frame from: the built-ins plus
+ // their own org's. Any member may call it.
+ ListFrameTemplates(context.Context, *connect.Request[v1.ListFrameTemplatesRequest]) (*connect.Response[v1.ListFrameTemplatesResponse], error)
+ // Read - one template with its prefill and rules. 404 for a built-in that
+ // does not exist and for another org's template alike, so existence does not
+ // leak.
+ GetFrameTemplate(context.Context, *connect.Request[v1.GetFrameTemplateRequest]) (*connect.Response[v1.GetFrameTemplateResponse], error)
+ // Admin only - create an org template.
+ CreateFrameTemplate(context.Context, *connect.Request[v1.CreateFrameTemplateRequest]) (*connect.Response[v1.CreateFrameTemplateResponse], error)
+ // Admin only - replace an org template's content. Built-ins are immutable.
+ UpdateFrameTemplate(context.Context, *connect.Request[v1.UpdateFrameTemplateRequest]) (*connect.Response[v1.UpdateFrameTemplateResponse], error)
+ // Admin only - delete an org template. Frames already created from it are
+ // unaffected: a template is copied once and never referenced again.
+ DeleteFrameTemplate(context.Context, *connect.Request[v1.DeleteFrameTemplateRequest]) (*connect.Response[v1.DeleteFrameTemplateResponse], error)
// Admin only - list the caller's org members.
ListOrgMembers(context.Context, *connect.Request[v1.ListOrgMembersRequest]) (*connect.Response[v1.ListOrgMembersResponse], error)
// Admin only - add a member to the caller's org by email (pending until login).
@@ -156,6 +185,36 @@ func NewFrameServiceClient(httpClient connect.HTTPClient, baseURL string, opts .
connect.WithSchema(frameServiceMethods.ByName("ConvertFrame")),
connect.WithClientOptions(opts...),
),
+ listFrameTemplates: connect.NewClient[v1.ListFrameTemplatesRequest, v1.ListFrameTemplatesResponse](
+ httpClient,
+ baseURL+FrameServiceListFrameTemplatesProcedure,
+ connect.WithSchema(frameServiceMethods.ByName("ListFrameTemplates")),
+ connect.WithClientOptions(opts...),
+ ),
+ getFrameTemplate: connect.NewClient[v1.GetFrameTemplateRequest, v1.GetFrameTemplateResponse](
+ httpClient,
+ baseURL+FrameServiceGetFrameTemplateProcedure,
+ connect.WithSchema(frameServiceMethods.ByName("GetFrameTemplate")),
+ connect.WithClientOptions(opts...),
+ ),
+ createFrameTemplate: connect.NewClient[v1.CreateFrameTemplateRequest, v1.CreateFrameTemplateResponse](
+ httpClient,
+ baseURL+FrameServiceCreateFrameTemplateProcedure,
+ connect.WithSchema(frameServiceMethods.ByName("CreateFrameTemplate")),
+ connect.WithClientOptions(opts...),
+ ),
+ updateFrameTemplate: connect.NewClient[v1.UpdateFrameTemplateRequest, v1.UpdateFrameTemplateResponse](
+ httpClient,
+ baseURL+FrameServiceUpdateFrameTemplateProcedure,
+ connect.WithSchema(frameServiceMethods.ByName("UpdateFrameTemplate")),
+ connect.WithClientOptions(opts...),
+ ),
+ deleteFrameTemplate: connect.NewClient[v1.DeleteFrameTemplateRequest, v1.DeleteFrameTemplateResponse](
+ httpClient,
+ baseURL+FrameServiceDeleteFrameTemplateProcedure,
+ connect.WithSchema(frameServiceMethods.ByName("DeleteFrameTemplate")),
+ connect.WithClientOptions(opts...),
+ ),
listOrgMembers: connect.NewClient[v1.ListOrgMembersRequest, v1.ListOrgMembersResponse](
httpClient,
baseURL+FrameServiceListOrgMembersProcedure,
@@ -185,18 +244,23 @@ func NewFrameServiceClient(httpClient connect.HTTPClient, baseURL string, opts .
// frameServiceClient implements FrameServiceClient.
type frameServiceClient struct {
- publishFrame *connect.Client[v1.PublishFrameRequest, v1.PublishFrameResponse]
- listFrames *connect.Client[v1.ListFramesRequest, v1.ListFramesResponse]
- getFrame *connect.Client[v1.GetFrameRequest, v1.GetFrameResponse]
- resolveFrame *connect.Client[v1.ResolveFrameRequest, v1.ResolveFrameResponse]
- getMe *connect.Client[v1.GetMeRequest, v1.GetMeResponse]
- listFrameVersions *connect.Client[v1.ListFrameVersionsRequest, v1.ListFrameVersionsResponse]
- deleteFrame *connect.Client[v1.DeleteFrameRequest, v1.DeleteFrameResponse]
- convertFrame *connect.Client[v1.ConvertFrameRequest, v1.ConvertFrameResponse]
- listOrgMembers *connect.Client[v1.ListOrgMembersRequest, v1.ListOrgMembersResponse]
- addOrgMember *connect.Client[v1.AddOrgMemberRequest, v1.AddOrgMemberResponse]
- setMemberRole *connect.Client[v1.SetMemberRoleRequest, v1.SetMemberRoleResponse]
- removeOrgMember *connect.Client[v1.RemoveOrgMemberRequest, v1.RemoveOrgMemberResponse]
+ publishFrame *connect.Client[v1.PublishFrameRequest, v1.PublishFrameResponse]
+ listFrames *connect.Client[v1.ListFramesRequest, v1.ListFramesResponse]
+ getFrame *connect.Client[v1.GetFrameRequest, v1.GetFrameResponse]
+ resolveFrame *connect.Client[v1.ResolveFrameRequest, v1.ResolveFrameResponse]
+ getMe *connect.Client[v1.GetMeRequest, v1.GetMeResponse]
+ listFrameVersions *connect.Client[v1.ListFrameVersionsRequest, v1.ListFrameVersionsResponse]
+ deleteFrame *connect.Client[v1.DeleteFrameRequest, v1.DeleteFrameResponse]
+ convertFrame *connect.Client[v1.ConvertFrameRequest, v1.ConvertFrameResponse]
+ listFrameTemplates *connect.Client[v1.ListFrameTemplatesRequest, v1.ListFrameTemplatesResponse]
+ getFrameTemplate *connect.Client[v1.GetFrameTemplateRequest, v1.GetFrameTemplateResponse]
+ createFrameTemplate *connect.Client[v1.CreateFrameTemplateRequest, v1.CreateFrameTemplateResponse]
+ updateFrameTemplate *connect.Client[v1.UpdateFrameTemplateRequest, v1.UpdateFrameTemplateResponse]
+ deleteFrameTemplate *connect.Client[v1.DeleteFrameTemplateRequest, v1.DeleteFrameTemplateResponse]
+ listOrgMembers *connect.Client[v1.ListOrgMembersRequest, v1.ListOrgMembersResponse]
+ addOrgMember *connect.Client[v1.AddOrgMemberRequest, v1.AddOrgMemberResponse]
+ setMemberRole *connect.Client[v1.SetMemberRoleRequest, v1.SetMemberRoleResponse]
+ removeOrgMember *connect.Client[v1.RemoveOrgMemberRequest, v1.RemoveOrgMemberResponse]
}
// PublishFrame calls frames.v1.FrameService.PublishFrame.
@@ -239,6 +303,31 @@ func (c *frameServiceClient) ConvertFrame(ctx context.Context, req *connect.Requ
return c.convertFrame.CallUnary(ctx, req)
}
+// ListFrameTemplates calls frames.v1.FrameService.ListFrameTemplates.
+func (c *frameServiceClient) ListFrameTemplates(ctx context.Context, req *connect.Request[v1.ListFrameTemplatesRequest]) (*connect.Response[v1.ListFrameTemplatesResponse], error) {
+ return c.listFrameTemplates.CallUnary(ctx, req)
+}
+
+// GetFrameTemplate calls frames.v1.FrameService.GetFrameTemplate.
+func (c *frameServiceClient) GetFrameTemplate(ctx context.Context, req *connect.Request[v1.GetFrameTemplateRequest]) (*connect.Response[v1.GetFrameTemplateResponse], error) {
+ return c.getFrameTemplate.CallUnary(ctx, req)
+}
+
+// CreateFrameTemplate calls frames.v1.FrameService.CreateFrameTemplate.
+func (c *frameServiceClient) CreateFrameTemplate(ctx context.Context, req *connect.Request[v1.CreateFrameTemplateRequest]) (*connect.Response[v1.CreateFrameTemplateResponse], error) {
+ return c.createFrameTemplate.CallUnary(ctx, req)
+}
+
+// UpdateFrameTemplate calls frames.v1.FrameService.UpdateFrameTemplate.
+func (c *frameServiceClient) UpdateFrameTemplate(ctx context.Context, req *connect.Request[v1.UpdateFrameTemplateRequest]) (*connect.Response[v1.UpdateFrameTemplateResponse], error) {
+ return c.updateFrameTemplate.CallUnary(ctx, req)
+}
+
+// DeleteFrameTemplate calls frames.v1.FrameService.DeleteFrameTemplate.
+func (c *frameServiceClient) DeleteFrameTemplate(ctx context.Context, req *connect.Request[v1.DeleteFrameTemplateRequest]) (*connect.Response[v1.DeleteFrameTemplateResponse], error) {
+ return c.deleteFrameTemplate.CallUnary(ctx, req)
+}
+
// ListOrgMembers calls frames.v1.FrameService.ListOrgMembers.
func (c *frameServiceClient) ListOrgMembers(ctx context.Context, req *connect.Request[v1.ListOrgMembersRequest]) (*connect.Response[v1.ListOrgMembersResponse], error) {
return c.listOrgMembers.CallUnary(ctx, req)
@@ -278,6 +367,20 @@ type FrameServiceHandler interface {
// Pure conversion between the canonical slot YAML and the spec-conformant
// .frame.md form. Stateless and unauthenticated beyond org membership.
ConvertFrame(context.Context, *connect.Request[v1.ConvertFrameRequest]) (*connect.Response[v1.ConvertFrameResponse], error)
+ // Read - the templates the caller may start a Frame from: the built-ins plus
+ // their own org's. Any member may call it.
+ ListFrameTemplates(context.Context, *connect.Request[v1.ListFrameTemplatesRequest]) (*connect.Response[v1.ListFrameTemplatesResponse], error)
+ // Read - one template with its prefill and rules. 404 for a built-in that
+ // does not exist and for another org's template alike, so existence does not
+ // leak.
+ GetFrameTemplate(context.Context, *connect.Request[v1.GetFrameTemplateRequest]) (*connect.Response[v1.GetFrameTemplateResponse], error)
+ // Admin only - create an org template.
+ CreateFrameTemplate(context.Context, *connect.Request[v1.CreateFrameTemplateRequest]) (*connect.Response[v1.CreateFrameTemplateResponse], error)
+ // Admin only - replace an org template's content. Built-ins are immutable.
+ UpdateFrameTemplate(context.Context, *connect.Request[v1.UpdateFrameTemplateRequest]) (*connect.Response[v1.UpdateFrameTemplateResponse], error)
+ // Admin only - delete an org template. Frames already created from it are
+ // unaffected: a template is copied once and never referenced again.
+ DeleteFrameTemplate(context.Context, *connect.Request[v1.DeleteFrameTemplateRequest]) (*connect.Response[v1.DeleteFrameTemplateResponse], error)
// Admin only - list the caller's org members.
ListOrgMembers(context.Context, *connect.Request[v1.ListOrgMembersRequest]) (*connect.Response[v1.ListOrgMembersResponse], error)
// Admin only - add a member to the caller's org by email (pending until login).
@@ -343,6 +446,36 @@ func NewFrameServiceHandler(svc FrameServiceHandler, opts ...connect.HandlerOpti
connect.WithSchema(frameServiceMethods.ByName("ConvertFrame")),
connect.WithHandlerOptions(opts...),
)
+ frameServiceListFrameTemplatesHandler := connect.NewUnaryHandler(
+ FrameServiceListFrameTemplatesProcedure,
+ svc.ListFrameTemplates,
+ connect.WithSchema(frameServiceMethods.ByName("ListFrameTemplates")),
+ connect.WithHandlerOptions(opts...),
+ )
+ frameServiceGetFrameTemplateHandler := connect.NewUnaryHandler(
+ FrameServiceGetFrameTemplateProcedure,
+ svc.GetFrameTemplate,
+ connect.WithSchema(frameServiceMethods.ByName("GetFrameTemplate")),
+ connect.WithHandlerOptions(opts...),
+ )
+ frameServiceCreateFrameTemplateHandler := connect.NewUnaryHandler(
+ FrameServiceCreateFrameTemplateProcedure,
+ svc.CreateFrameTemplate,
+ connect.WithSchema(frameServiceMethods.ByName("CreateFrameTemplate")),
+ connect.WithHandlerOptions(opts...),
+ )
+ frameServiceUpdateFrameTemplateHandler := connect.NewUnaryHandler(
+ FrameServiceUpdateFrameTemplateProcedure,
+ svc.UpdateFrameTemplate,
+ connect.WithSchema(frameServiceMethods.ByName("UpdateFrameTemplate")),
+ connect.WithHandlerOptions(opts...),
+ )
+ frameServiceDeleteFrameTemplateHandler := connect.NewUnaryHandler(
+ FrameServiceDeleteFrameTemplateProcedure,
+ svc.DeleteFrameTemplate,
+ connect.WithSchema(frameServiceMethods.ByName("DeleteFrameTemplate")),
+ connect.WithHandlerOptions(opts...),
+ )
frameServiceListOrgMembersHandler := connect.NewUnaryHandler(
FrameServiceListOrgMembersProcedure,
svc.ListOrgMembers,
@@ -385,6 +518,16 @@ func NewFrameServiceHandler(svc FrameServiceHandler, opts ...connect.HandlerOpti
frameServiceDeleteFrameHandler.ServeHTTP(w, r)
case FrameServiceConvertFrameProcedure:
frameServiceConvertFrameHandler.ServeHTTP(w, r)
+ case FrameServiceListFrameTemplatesProcedure:
+ frameServiceListFrameTemplatesHandler.ServeHTTP(w, r)
+ case FrameServiceGetFrameTemplateProcedure:
+ frameServiceGetFrameTemplateHandler.ServeHTTP(w, r)
+ case FrameServiceCreateFrameTemplateProcedure:
+ frameServiceCreateFrameTemplateHandler.ServeHTTP(w, r)
+ case FrameServiceUpdateFrameTemplateProcedure:
+ frameServiceUpdateFrameTemplateHandler.ServeHTTP(w, r)
+ case FrameServiceDeleteFrameTemplateProcedure:
+ frameServiceDeleteFrameTemplateHandler.ServeHTTP(w, r)
case FrameServiceListOrgMembersProcedure:
frameServiceListOrgMembersHandler.ServeHTTP(w, r)
case FrameServiceAddOrgMemberProcedure:
@@ -434,6 +577,26 @@ func (UnimplementedFrameServiceHandler) ConvertFrame(context.Context, *connect.R
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("frames.v1.FrameService.ConvertFrame is not implemented"))
}
+func (UnimplementedFrameServiceHandler) ListFrameTemplates(context.Context, *connect.Request[v1.ListFrameTemplatesRequest]) (*connect.Response[v1.ListFrameTemplatesResponse], error) {
+ return nil, connect.NewError(connect.CodeUnimplemented, errors.New("frames.v1.FrameService.ListFrameTemplates is not implemented"))
+}
+
+func (UnimplementedFrameServiceHandler) GetFrameTemplate(context.Context, *connect.Request[v1.GetFrameTemplateRequest]) (*connect.Response[v1.GetFrameTemplateResponse], error) {
+ return nil, connect.NewError(connect.CodeUnimplemented, errors.New("frames.v1.FrameService.GetFrameTemplate is not implemented"))
+}
+
+func (UnimplementedFrameServiceHandler) CreateFrameTemplate(context.Context, *connect.Request[v1.CreateFrameTemplateRequest]) (*connect.Response[v1.CreateFrameTemplateResponse], error) {
+ return nil, connect.NewError(connect.CodeUnimplemented, errors.New("frames.v1.FrameService.CreateFrameTemplate is not implemented"))
+}
+
+func (UnimplementedFrameServiceHandler) UpdateFrameTemplate(context.Context, *connect.Request[v1.UpdateFrameTemplateRequest]) (*connect.Response[v1.UpdateFrameTemplateResponse], error) {
+ return nil, connect.NewError(connect.CodeUnimplemented, errors.New("frames.v1.FrameService.UpdateFrameTemplate is not implemented"))
+}
+
+func (UnimplementedFrameServiceHandler) DeleteFrameTemplate(context.Context, *connect.Request[v1.DeleteFrameTemplateRequest]) (*connect.Response[v1.DeleteFrameTemplateResponse], error) {
+ return nil, connect.NewError(connect.CodeUnimplemented, errors.New("frames.v1.FrameService.DeleteFrameTemplate is not implemented"))
+}
+
func (UnimplementedFrameServiceHandler) ListOrgMembers(context.Context, *connect.Request[v1.ListOrgMembersRequest]) (*connect.Response[v1.ListOrgMembersResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("frames.v1.FrameService.ListOrgMembers is not implemented"))
}
diff --git a/gen/ts/frames/v1/frame_pb.ts b/gen/ts/frames/v1/frame_pb.ts
index 90e4dd1..49d8676 100644
--- a/gen/ts/frames/v1/frame_pb.ts
+++ b/gen/ts/frames/v1/frame_pb.ts
@@ -2,8 +2,8 @@
// @generated from file frames/v1/frame.proto (package frames.v1, syntax proto3)
/* eslint-disable */
-import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2";
-import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2";
+import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2";
+import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2";
import type { Timestamp } from "@bufbuild/protobuf/wkt";
import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt";
import type { Message } from "@bufbuild/protobuf";
@@ -12,7 +12,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file frames/v1/frame.proto.
*/
export const file_frames_v1_frame: GenFile = /*@__PURE__*/
- fileDesc("ChVmcmFtZXMvdjEvZnJhbWUucHJvdG8SCWZyYW1lcy52MSJlCgNPcmcSCgoCaWQYASABKAkSDAoEc2x1ZxgCIAEoCRIUCgxkaXNwbGF5X25hbWUYAyABKAkSLgoKY3JlYXRlZF9hdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAieQoKTWVtYmVyc2hpcBIOCgZvcmdfaWQYASABKAkSEAoIdXNlcl9zdWIYAiABKAkSDAoEcm9sZRgDIAEoCRIsCghhZGRlZF9hdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASDQoFZW1haWwYBSABKAki0QEKBUZyYW1lEgoKAmlkGAEgASgJEg4KBm9yZ19pZBgCIAEoCRIMCgRuYW1lGAMgASgJEhMKC2Rlc2NyaXB0aW9uGAQgASgJEhEKCW93bmVyX3N1YhgFIAEoCRIWCg5sYXRlc3RfdmVyc2lvbhgGIAEoCRIuCgpjcmVhdGVkX2F0GAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgp1cGRhdGVkX2F0GAggASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCIpCglQYXJlbnRSZWYSCwoDcmVmGAEgASgJEg8KB3ZlcnNpb24YAiABKAkirwEKDEZyYW1lVmVyc2lvbhIPCgd2ZXJzaW9uGAEgASgJEhEKCWNoYW5nZWxvZxgCIAEoCRIOCgZkaWdlc3QYAyABKAkSEgoKc2l6ZV9ieXRlcxgEIAEoAxIUCgxwdWJsaXNoZWRfYnkYBSABKAkSMAoMcHVibGlzaGVkX2F0GAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIPCgdjb250ZW50GAcgASgMIjMKC1Blcm1pc3Npb25zEhAKCGNhbl9lZGl0GAEgASgIEhIKCmNhbl9kZWxldGUYAiABKAgiywEKDEZyYW1lU3VtbWFyeRIQCghvcmdfc2x1ZxgBIAEoCRIMCgRuYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJEhEKCW93bmVyX3N1YhgEIAEoCRIWCg5sYXRlc3RfdmVyc2lvbhgFIAEoCRIuCgp1cGRhdGVkX2F0GAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIrCgtwZXJtaXNzaW9ucxgHIAEoCzIWLmZyYW1lcy52MS5QZXJtaXNzaW9ucyKBAQoTRnJhbWVWZXJzaW9uU3VtbWFyeRIPCgd2ZXJzaW9uGAEgASgJEhEKCWNoYW5nZWxvZxgCIAEoCRIUCgxwdWJsaXNoZWRfYnkYAyABKAkSMAoMcHVibGlzaGVkX2F0GAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEKfAQoNY29tLmZyYW1lcy52MUIKRnJhbWVQcm90b1ABWj1naXRodWIuY29tL25lYmFyaS1kZXYvbmViYXJpLWZyYW1lcy9nZW4vZ28vZnJhbWVzL3YxO2ZyYW1lc3YxogIDRlhYqgIJRnJhbWVzLlYxygIJRnJhbWVzXFYx4gIVRnJhbWVzXFYxXEdQQk1ldGFkYXRh6gIKRnJhbWVzOjpWMWIGcHJvdG8z", [file_google_protobuf_timestamp]);
+ fileDesc("ChVmcmFtZXMvdjEvZnJhbWUucHJvdG8SCWZyYW1lcy52MSJlCgNPcmcSCgoCaWQYASABKAkSDAoEc2x1ZxgCIAEoCRIUCgxkaXNwbGF5X25hbWUYAyABKAkSLgoKY3JlYXRlZF9hdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAieQoKTWVtYmVyc2hpcBIOCgZvcmdfaWQYASABKAkSEAoIdXNlcl9zdWIYAiABKAkSDAoEcm9sZRgDIAEoCRIsCghhZGRlZF9hdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASDQoFZW1haWwYBSABKAki0QEKBUZyYW1lEgoKAmlkGAEgASgJEg4KBm9yZ19pZBgCIAEoCRIMCgRuYW1lGAMgASgJEhMKC2Rlc2NyaXB0aW9uGAQgASgJEhEKCW93bmVyX3N1YhgFIAEoCRIWCg5sYXRlc3RfdmVyc2lvbhgGIAEoCRIuCgpjcmVhdGVkX2F0GAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgp1cGRhdGVkX2F0GAggASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCIpCglQYXJlbnRSZWYSCwoDcmVmGAEgASgJEg8KB3ZlcnNpb24YAiABKAkirwEKDEZyYW1lVmVyc2lvbhIPCgd2ZXJzaW9uGAEgASgJEhEKCWNoYW5nZWxvZxgCIAEoCRIOCgZkaWdlc3QYAyABKAkSEgoKc2l6ZV9ieXRlcxgEIAEoAxIUCgxwdWJsaXNoZWRfYnkYBSABKAkSMAoMcHVibGlzaGVkX2F0GAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIPCgdjb250ZW50GAcgASgMIjMKC1Blcm1pc3Npb25zEhAKCGNhbl9lZGl0GAEgASgIEhIKCmNhbl9kZWxldGUYAiABKAgiywEKDEZyYW1lU3VtbWFyeRIQCghvcmdfc2x1ZxgBIAEoCRIMCgRuYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJEhEKCW93bmVyX3N1YhgEIAEoCRIWCg5sYXRlc3RfdmVyc2lvbhgFIAEoCRIuCgp1cGRhdGVkX2F0GAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIrCgtwZXJtaXNzaW9ucxgHIAEoCzIWLmZyYW1lcy52MS5QZXJtaXNzaW9ucyKBAQoTRnJhbWVWZXJzaW9uU3VtbWFyeRIPCgd2ZXJzaW9uGAEgASgJEhEKCWNoYW5nZWxvZxgCIAEoCRIUCgxwdWJsaXNoZWRfYnkYAyABKAkSMAoMcHVibGlzaGVkX2F0GAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCJACglGaWVsZFJ1bGUSJQoFbGV2ZWwYASABKA4yFi5mcmFtZXMudjEuUmVxdWlyZW1lbnQSDAoEbm90ZRgCIAEoCSJXChRGcmFtZVRlbXBsYXRlU3VtbWFyeRIKCgJpZBgBIAEoCRINCgV0aXRsZRgCIAEoCRITCgtkZXNjcmlwdGlvbhgDIAEoCRIPCgdidWlsdGluGAQgASgIIukBCg1GcmFtZVRlbXBsYXRlEgoKAmlkGAEgASgJEg0KBXRpdGxlGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJEg8KB2J1aWx0aW4YBCABKAgSDwoHcHJlZmlsbBgFIAEoDBI9CgtmaWVsZF9ydWxlcxgGIAMoCzIoLmZyYW1lcy52MS5GcmFtZVRlbXBsYXRlLkZpZWxkUnVsZXNFbnRyeRpHCg9GaWVsZFJ1bGVzRW50cnkSCwoDa2V5GAEgASgJEiMKBXZhbHVlGAIgASgLMhQuZnJhbWVzLnYxLkZpZWxkUnVsZToCOAEqewoLUmVxdWlyZW1lbnQSGwoXUkVRVUlSRU1FTlRfVU5TUEVDSUZJRUQQABIYChRSRVFVSVJFTUVOVF9PUFRJT05BTBABEhsKF1JFUVVJUkVNRU5UX1JFQ09NTUVOREVEEAISGAoUUkVRVUlSRU1FTlRfUkVRVUlSRUQQA0KfAQoNY29tLmZyYW1lcy52MUIKRnJhbWVQcm90b1ABWj1naXRodWIuY29tL25lYmFyaS1kZXYvbmViYXJpLWZyYW1lcy9nZW4vZ28vZnJhbWVzL3YxO2ZyYW1lc3YxogIDRlhYqgIJRnJhbWVzLlYxygIJRnJhbWVzXFYx4gIVRnJhbWVzXFYxXEdQQk1ldGFkYXRh6gIKRnJhbWVzOjpWMWIGcHJvdG8z", [file_google_protobuf_timestamp]);
/**
* @generated from message frames.v1.Org
@@ -315,3 +315,154 @@ export type FrameVersionSummary = Message<"frames.v1.FrameVersionSummary"> & {
export const FrameVersionSummarySchema: GenMessage = /*@__PURE__*/
messageDesc(file_frames_v1_frame, 7);
+/**
+ * FieldRule is a template's expectation for one slot. `note` is the template
+ * author's own guidance on what belongs there: the seeded authoring form shows
+ * it in place of the generic per-slot hint, and an MCP client asks it as a
+ * question. A message rather than a bare Requirement because widening a map's
+ * value type later would be a breaking wire change.
+ *
+ * @generated from message frames.v1.FieldRule
+ */
+export type FieldRule = Message<"frames.v1.FieldRule"> & {
+ /**
+ * @generated from field: frames.v1.Requirement level = 1;
+ */
+ level: Requirement;
+
+ /**
+ * @generated from field: string note = 2;
+ */
+ note: string;
+};
+
+/**
+ * Describes the message frames.v1.FieldRule.
+ * Use `create(FieldRuleSchema)` to create a new message.
+ */
+export const FieldRuleSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_frames_v1_frame, 8);
+
+/**
+ * FrameTemplateSummary is what a picker needs: no prefill, so listing stays
+ * cheap. `builtin` is computed server-side so no client has to parse an id.
+ *
+ * @generated from message frames.v1.FrameTemplateSummary
+ */
+export type FrameTemplateSummary = Message<"frames.v1.FrameTemplateSummary"> & {
+ /**
+ * @generated from field: string id = 1;
+ */
+ id: string;
+
+ /**
+ * @generated from field: string title = 2;
+ */
+ title: string;
+
+ /**
+ * @generated from field: string description = 3;
+ */
+ description: string;
+
+ /**
+ * @generated from field: bool builtin = 4;
+ */
+ builtin: boolean;
+};
+
+/**
+ * Describes the message frames.v1.FrameTemplateSummary.
+ * Use `create(FrameTemplateSummarySchema)` to create a new message.
+ */
+export const FrameTemplateSummarySchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_frames_v1_frame, 9);
+
+/**
+ * @generated from message frames.v1.FrameTemplate
+ */
+export type FrameTemplate = Message<"frames.v1.FrameTemplate"> & {
+ /**
+ * @generated from field: string id = 1;
+ */
+ id: string;
+
+ /**
+ * @generated from field: string title = 2;
+ */
+ title: string;
+
+ /**
+ * @generated from field: string description = 3;
+ */
+ description: string;
+
+ /**
+ * @generated from field: bool builtin = 4;
+ */
+ builtin: boolean;
+
+ /**
+ * Canonical YAML carrying only `slots` and `extends`. Bytes rather than a
+ * structured message on purpose: a message mirroring the slot schema would be
+ * a second place to update whenever frames.SlotTable changes, which is what
+ * the reflective guards in the MCP tests exist to catch.
+ *
+ * @generated from field: bytes prefill = 5;
+ */
+ prefill: Uint8Array;
+
+ /**
+ * Slot key (terminology, rules, skills, prompts, tool_specs, goals, style,
+ * norms, architecture, business_process) -> rule. Keyed by string, so adding
+ * a slot does not change this shape either.
+ *
+ * @generated from field: map field_rules = 6;
+ */
+ fieldRules: { [key: string]: FieldRule };
+};
+
+/**
+ * Describes the message frames.v1.FrameTemplate.
+ * Use `create(FrameTemplateSchema)` to create a new message.
+ */
+export const FrameTemplateSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_frames_v1_frame, 10);
+
+/**
+ * Requirement is how strongly a template wants a slot filled. Stricter than the
+ * Frame schema, which requires no slot at all: an org uses it to hold its own
+ * Frames to a house standard the spec does not impose.
+ *
+ * @generated from enum frames.v1.Requirement
+ */
+export enum Requirement {
+ /**
+ * treated as optional
+ *
+ * @generated from enum value: REQUIREMENT_UNSPECIFIED = 0;
+ */
+ UNSPECIFIED = 0,
+
+ /**
+ * @generated from enum value: REQUIREMENT_OPTIONAL = 1;
+ */
+ OPTIONAL = 1,
+
+ /**
+ * @generated from enum value: REQUIREMENT_RECOMMENDED = 2;
+ */
+ RECOMMENDED = 2,
+
+ /**
+ * @generated from enum value: REQUIREMENT_REQUIRED = 3;
+ */
+ REQUIRED = 3,
+}
+
+/**
+ * Describes the enum frames.v1.Requirement.
+ */
+export const RequirementSchema: GenEnum = /*@__PURE__*/
+ enumDesc(file_frames_v1_frame, 0);
+
diff --git a/gen/ts/frames/v1/frame_service_pb.ts b/gen/ts/frames/v1/frame_service_pb.ts
index 6431d93..884003c 100644
--- a/gen/ts/frames/v1/frame_service_pb.ts
+++ b/gen/ts/frames/v1/frame_service_pb.ts
@@ -4,7 +4,7 @@
import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2";
import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2";
-import type { Frame, FrameSummary, FrameVersion, FrameVersionSummary, Membership, Org, ParentRef, Permissions } from "./frame_pb";
+import type { FieldRule, Frame, FrameSummary, FrameTemplate, FrameTemplateSummary, FrameVersion, FrameVersionSummary, Membership, Org, ParentRef, Permissions } from "./frame_pb";
import { file_frames_v1_frame } from "./frame_pb";
import type { Message } from "@bufbuild/protobuf";
@@ -12,7 +12,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file frames/v1/frame_service.proto.
*/
export const file_frames_v1_frame_service: GenFile = /*@__PURE__*/
- fileDesc("Ch1mcmFtZXMvdjEvZnJhbWVfc2VydmljZS5wcm90bxIJZnJhbWVzLnYxIjkKE1B1Ymxpc2hGcmFtZVJlcXVlc3QSDwoHY29udGVudBgBIAEoDBIRCgljaGFuZ2Vsb2cYAiABKAkiYQoUUHVibGlzaEZyYW1lUmVzcG9uc2USHwoFZnJhbWUYASABKAsyEC5mcmFtZXMudjEuRnJhbWUSKAoHdmVyc2lvbhgCIAEoCzIXLmZyYW1lcy52MS5GcmFtZVZlcnNpb24iEwoRTGlzdEZyYW1lc1JlcXVlc3QiUQoSTGlzdEZyYW1lc1Jlc3BvbnNlEicKBmZyYW1lcxgBIAMoCzIXLmZyYW1lcy52MS5GcmFtZVN1bW1hcnkSEgoKY2FuX2NyZWF0ZRgCIAEoCCJCCg9HZXRGcmFtZVJlcXVlc3QSEAoIb3JnX3NsdWcYASABKAkSDAoEbmFtZRgCIAEoCRIPCgd2ZXJzaW9uGAMgASgJIsMBChBHZXRGcmFtZVJlc3BvbnNlEh8KBWZyYW1lGAEgASgLMhAuZnJhbWVzLnYxLkZyYW1lEigKB3ZlcnNpb24YAiABKAsyFy5mcmFtZXMudjEuRnJhbWVWZXJzaW9uEiUKB2V4dGVuZHMYAyADKAsyFC5mcmFtZXMudjEuUGFyZW50UmVmEhAKCGV4Y2x1ZGVzGAQgAygJEisKC3Blcm1pc3Npb25zGAUgASgLMhYuZnJhbWVzLnYxLlBlcm1pc3Npb25zIkYKE1Jlc29sdmVGcmFtZVJlcXVlc3QSEAoIb3JnX3NsdWcYASABKAkSDAoEbmFtZRgCIAEoCRIPCgd2ZXJzaW9uGAMgASgJIjAKFFJlc29sdmVGcmFtZVJlc3BvbnNlEhgKEHJlc29sdmVkX2NvbnRlbnQYASABKAwiDgoMR2V0TWVSZXF1ZXN0Im4KDUdldE1lUmVzcG9uc2USDwoHc3ViamVjdBgBIAEoCRINCgVlbWFpbBgCIAEoCRIbCgNvcmcYAyABKAsyDi5mcmFtZXMudjEuT3JnEgwKBHJvbGUYBCABKAkSEgoKY2FuX2NyZWF0ZRgFIAEoCCI6ChhMaXN0RnJhbWVWZXJzaW9uc1JlcXVlc3QSEAoIb3JnX3NsdWcYASABKAkSDAoEbmFtZRgCIAEoCSJNChlMaXN0RnJhbWVWZXJzaW9uc1Jlc3BvbnNlEjAKCHZlcnNpb25zGAEgAygLMh4uZnJhbWVzLnYxLkZyYW1lVmVyc2lvblN1bW1hcnkiMAoORmllbGRWaW9sYXRpb24SDQoFZmllbGQYASABKAkSDwoHbWVzc2FnZRgCIAEoCSJACg9GaWVsZFZpb2xhdGlvbnMSLQoKdmlvbGF0aW9ucxgBIAMoCzIZLmZyYW1lcy52MS5GaWVsZFZpb2xhdGlvbiJDChNDb252ZXJ0RnJhbWVSZXF1ZXN0Eg4KBHlhbWwYASABKAxIABISCghtYXJrZG93bhgCIAEoDEgAQggKBnNvdXJjZSI2ChRDb252ZXJ0RnJhbWVSZXNwb25zZRIMCgR5YW1sGAEgASgMEhAKCG1hcmtkb3duGAIgASgMIkMKEkRlbGV0ZUZyYW1lUmVxdWVzdBIQCghvcmdfc2x1ZxgBIAEoCRIMCgRuYW1lGAIgASgJEg0KBWZvcmNlGAMgASgIIhUKE0RlbGV0ZUZyYW1lUmVzcG9uc2UiKAoNRGVsZXRlQmxvY2tlZBIXCg9ibG9ja2luZ19mcmFtZXMYASADKAkiFwoVTGlzdE9yZ01lbWJlcnNSZXF1ZXN0IkAKFkxpc3RPcmdNZW1iZXJzUmVzcG9uc2USJgoHbWVtYmVycxgBIAMoCzIVLmZyYW1lcy52MS5NZW1iZXJzaGlwIjIKE0FkZE9yZ01lbWJlclJlcXVlc3QSDQoFZW1haWwYASABKAkSDAoEcm9sZRgCIAEoCSI9ChRBZGRPcmdNZW1iZXJSZXNwb25zZRIlCgZtZW1iZXIYASABKAsyFS5mcmFtZXMudjEuTWVtYmVyc2hpcCJFChRTZXRNZW1iZXJSb2xlUmVxdWVzdBIQCgh1c2VyX3N1YhgBIAEoCRINCgVlbWFpbBgCIAEoCRIMCgRyb2xlGAMgASgJIj4KFVNldE1lbWJlclJvbGVSZXNwb25zZRIlCgZtZW1iZXIYASABKAsyFS5mcmFtZXMudjEuTWVtYmVyc2hpcCI5ChZSZW1vdmVPcmdNZW1iZXJSZXF1ZXN0EhAKCHVzZXJfc3ViGAEgASgJEg0KBWVtYWlsGAIgASgJIhkKF1JlbW92ZU9yZ01lbWJlclJlc3BvbnNlMtEHCgxGcmFtZVNlcnZpY2USTwoMUHVibGlzaEZyYW1lEh4uZnJhbWVzLnYxLlB1Ymxpc2hGcmFtZVJlcXVlc3QaHy5mcmFtZXMudjEuUHVibGlzaEZyYW1lUmVzcG9uc2USSQoKTGlzdEZyYW1lcxIcLmZyYW1lcy52MS5MaXN0RnJhbWVzUmVxdWVzdBodLmZyYW1lcy52MS5MaXN0RnJhbWVzUmVzcG9uc2USQwoIR2V0RnJhbWUSGi5mcmFtZXMudjEuR2V0RnJhbWVSZXF1ZXN0GhsuZnJhbWVzLnYxLkdldEZyYW1lUmVzcG9uc2USTwoMUmVzb2x2ZUZyYW1lEh4uZnJhbWVzLnYxLlJlc29sdmVGcmFtZVJlcXVlc3QaHy5mcmFtZXMudjEuUmVzb2x2ZUZyYW1lUmVzcG9uc2USOgoFR2V0TWUSFy5mcmFtZXMudjEuR2V0TWVSZXF1ZXN0GhguZnJhbWVzLnYxLkdldE1lUmVzcG9uc2USXgoRTGlzdEZyYW1lVmVyc2lvbnMSIy5mcmFtZXMudjEuTGlzdEZyYW1lVmVyc2lvbnNSZXF1ZXN0GiQuZnJhbWVzLnYxLkxpc3RGcmFtZVZlcnNpb25zUmVzcG9uc2USTAoLRGVsZXRlRnJhbWUSHS5mcmFtZXMudjEuRGVsZXRlRnJhbWVSZXF1ZXN0Gh4uZnJhbWVzLnYxLkRlbGV0ZUZyYW1lUmVzcG9uc2USTwoMQ29udmVydEZyYW1lEh4uZnJhbWVzLnYxLkNvbnZlcnRGcmFtZVJlcXVlc3QaHy5mcmFtZXMudjEuQ29udmVydEZyYW1lUmVzcG9uc2USVQoOTGlzdE9yZ01lbWJlcnMSIC5mcmFtZXMudjEuTGlzdE9yZ01lbWJlcnNSZXF1ZXN0GiEuZnJhbWVzLnYxLkxpc3RPcmdNZW1iZXJzUmVzcG9uc2USTwoMQWRkT3JnTWVtYmVyEh4uZnJhbWVzLnYxLkFkZE9yZ01lbWJlclJlcXVlc3QaHy5mcmFtZXMudjEuQWRkT3JnTWVtYmVyUmVzcG9uc2USUgoNU2V0TWVtYmVyUm9sZRIfLmZyYW1lcy52MS5TZXRNZW1iZXJSb2xlUmVxdWVzdBogLmZyYW1lcy52MS5TZXRNZW1iZXJSb2xlUmVzcG9uc2USWAoPUmVtb3ZlT3JnTWVtYmVyEiEuZnJhbWVzLnYxLlJlbW92ZU9yZ01lbWJlclJlcXVlc3QaIi5mcmFtZXMudjEuUmVtb3ZlT3JnTWVtYmVyUmVzcG9uc2VCpgEKDWNvbS5mcmFtZXMudjFCEUZyYW1lU2VydmljZVByb3RvUAFaPWdpdGh1Yi5jb20vbmViYXJpLWRldi9uZWJhcmktZnJhbWVzL2dlbi9nby9mcmFtZXMvdjE7ZnJhbWVzdjGiAgNGWFiqAglGcmFtZXMuVjHKAglGcmFtZXNcVjHiAhVGcmFtZXNcVjFcR1BCTWV0YWRhdGHqAgpGcmFtZXM6OlYxYgZwcm90bzM", [file_frames_v1_frame]);
+ fileDesc("Ch1mcmFtZXMvdjEvZnJhbWVfc2VydmljZS5wcm90bxIJZnJhbWVzLnYxIk4KE1B1Ymxpc2hGcmFtZVJlcXVlc3QSDwoHY29udGVudBgBIAEoDBIRCgljaGFuZ2Vsb2cYAiABKAkSEwoLdGVtcGxhdGVfaWQYAyABKAkiYQoUUHVibGlzaEZyYW1lUmVzcG9uc2USHwoFZnJhbWUYASABKAsyEC5mcmFtZXMudjEuRnJhbWUSKAoHdmVyc2lvbhgCIAEoCzIXLmZyYW1lcy52MS5GcmFtZVZlcnNpb24iEwoRTGlzdEZyYW1lc1JlcXVlc3QiUQoSTGlzdEZyYW1lc1Jlc3BvbnNlEicKBmZyYW1lcxgBIAMoCzIXLmZyYW1lcy52MS5GcmFtZVN1bW1hcnkSEgoKY2FuX2NyZWF0ZRgCIAEoCCJCCg9HZXRGcmFtZVJlcXVlc3QSEAoIb3JnX3NsdWcYASABKAkSDAoEbmFtZRgCIAEoCRIPCgd2ZXJzaW9uGAMgASgJIsMBChBHZXRGcmFtZVJlc3BvbnNlEh8KBWZyYW1lGAEgASgLMhAuZnJhbWVzLnYxLkZyYW1lEigKB3ZlcnNpb24YAiABKAsyFy5mcmFtZXMudjEuRnJhbWVWZXJzaW9uEiUKB2V4dGVuZHMYAyADKAsyFC5mcmFtZXMudjEuUGFyZW50UmVmEhAKCGV4Y2x1ZGVzGAQgAygJEisKC3Blcm1pc3Npb25zGAUgASgLMhYuZnJhbWVzLnYxLlBlcm1pc3Npb25zIkYKE1Jlc29sdmVGcmFtZVJlcXVlc3QSEAoIb3JnX3NsdWcYASABKAkSDAoEbmFtZRgCIAEoCRIPCgd2ZXJzaW9uGAMgASgJIjAKFFJlc29sdmVGcmFtZVJlc3BvbnNlEhgKEHJlc29sdmVkX2NvbnRlbnQYASABKAwiDgoMR2V0TWVSZXF1ZXN0Im4KDUdldE1lUmVzcG9uc2USDwoHc3ViamVjdBgBIAEoCRINCgVlbWFpbBgCIAEoCRIbCgNvcmcYAyABKAsyDi5mcmFtZXMudjEuT3JnEgwKBHJvbGUYBCABKAkSEgoKY2FuX2NyZWF0ZRgFIAEoCCI6ChhMaXN0RnJhbWVWZXJzaW9uc1JlcXVlc3QSEAoIb3JnX3NsdWcYASABKAkSDAoEbmFtZRgCIAEoCSJNChlMaXN0RnJhbWVWZXJzaW9uc1Jlc3BvbnNlEjAKCHZlcnNpb25zGAEgAygLMh4uZnJhbWVzLnYxLkZyYW1lVmVyc2lvblN1bW1hcnkiMAoORmllbGRWaW9sYXRpb24SDQoFZmllbGQYASABKAkSDwoHbWVzc2FnZRgCIAEoCSJACg9GaWVsZFZpb2xhdGlvbnMSLQoKdmlvbGF0aW9ucxgBIAMoCzIZLmZyYW1lcy52MS5GaWVsZFZpb2xhdGlvbiJDChNDb252ZXJ0RnJhbWVSZXF1ZXN0Eg4KBHlhbWwYASABKAxIABISCghtYXJrZG93bhgCIAEoDEgAQggKBnNvdXJjZSI2ChRDb252ZXJ0RnJhbWVSZXNwb25zZRIMCgR5YW1sGAEgASgMEhAKCG1hcmtkb3duGAIgASgMIkMKEkRlbGV0ZUZyYW1lUmVxdWVzdBIQCghvcmdfc2x1ZxgBIAEoCRIMCgRuYW1lGAIgASgJEg0KBWZvcmNlGAMgASgIIhUKE0RlbGV0ZUZyYW1lUmVzcG9uc2UiKAoNRGVsZXRlQmxvY2tlZBIXCg9ibG9ja2luZ19mcmFtZXMYASADKAkiFwoVTGlzdE9yZ01lbWJlcnNSZXF1ZXN0IkAKFkxpc3RPcmdNZW1iZXJzUmVzcG9uc2USJgoHbWVtYmVycxgBIAMoCzIVLmZyYW1lcy52MS5NZW1iZXJzaGlwIjIKE0FkZE9yZ01lbWJlclJlcXVlc3QSDQoFZW1haWwYASABKAkSDAoEcm9sZRgCIAEoCSI9ChRBZGRPcmdNZW1iZXJSZXNwb25zZRIlCgZtZW1iZXIYASABKAsyFS5mcmFtZXMudjEuTWVtYmVyc2hpcCJFChRTZXRNZW1iZXJSb2xlUmVxdWVzdBIQCgh1c2VyX3N1YhgBIAEoCRINCgVlbWFpbBgCIAEoCRIMCgRyb2xlGAMgASgJIj4KFVNldE1lbWJlclJvbGVSZXNwb25zZRIlCgZtZW1iZXIYASABKAsyFS5mcmFtZXMudjEuTWVtYmVyc2hpcCI5ChZSZW1vdmVPcmdNZW1iZXJSZXF1ZXN0EhAKCHVzZXJfc3ViGAEgASgJEg0KBWVtYWlsGAIgASgJIhkKF1JlbW92ZU9yZ01lbWJlclJlc3BvbnNlIhsKGUxpc3RGcmFtZVRlbXBsYXRlc1JlcXVlc3QiZAoaTGlzdEZyYW1lVGVtcGxhdGVzUmVzcG9uc2USMgoJdGVtcGxhdGVzGAEgAygLMh8uZnJhbWVzLnYxLkZyYW1lVGVtcGxhdGVTdW1tYXJ5EhIKCmNhbl9tYW5hZ2UYAiABKAgiJQoXR2V0RnJhbWVUZW1wbGF0ZVJlcXVlc3QSCgoCaWQYASABKAkiRgoYR2V0RnJhbWVUZW1wbGF0ZVJlc3BvbnNlEioKCHRlbXBsYXRlGAEgASgLMhguZnJhbWVzLnYxLkZyYW1lVGVtcGxhdGUi5gEKGkNyZWF0ZUZyYW1lVGVtcGxhdGVSZXF1ZXN0Eg0KBXRpdGxlGAEgASgJEhMKC2Rlc2NyaXB0aW9uGAIgASgJEg8KB3ByZWZpbGwYAyABKAwSSgoLZmllbGRfcnVsZXMYBCADKAsyNS5mcmFtZXMudjEuQ3JlYXRlRnJhbWVUZW1wbGF0ZVJlcXVlc3QuRmllbGRSdWxlc0VudHJ5GkcKD0ZpZWxkUnVsZXNFbnRyeRILCgNrZXkYASABKAkSIwoFdmFsdWUYAiABKAsyFC5mcmFtZXMudjEuRmllbGRSdWxlOgI4ASJJChtDcmVhdGVGcmFtZVRlbXBsYXRlUmVzcG9uc2USKgoIdGVtcGxhdGUYASABKAsyGC5mcmFtZXMudjEuRnJhbWVUZW1wbGF0ZSLyAQoaVXBkYXRlRnJhbWVUZW1wbGF0ZVJlcXVlc3QSCgoCaWQYASABKAkSDQoFdGl0bGUYAiABKAkSEwoLZGVzY3JpcHRpb24YAyABKAkSDwoHcHJlZmlsbBgEIAEoDBJKCgtmaWVsZF9ydWxlcxgFIAMoCzI1LmZyYW1lcy52MS5VcGRhdGVGcmFtZVRlbXBsYXRlUmVxdWVzdC5GaWVsZFJ1bGVzRW50cnkaRwoPRmllbGRSdWxlc0VudHJ5EgsKA2tleRgBIAEoCRIjCgV2YWx1ZRgCIAEoCzIULmZyYW1lcy52MS5GaWVsZFJ1bGU6AjgBIkkKG1VwZGF0ZUZyYW1lVGVtcGxhdGVSZXNwb25zZRIqCgh0ZW1wbGF0ZRgBIAEoCzIYLmZyYW1lcy52MS5GcmFtZVRlbXBsYXRlIigKGkRlbGV0ZUZyYW1lVGVtcGxhdGVSZXF1ZXN0EgoKAmlkGAEgASgJIh0KG0RlbGV0ZUZyYW1lVGVtcGxhdGVSZXNwb25zZTLDCwoMRnJhbWVTZXJ2aWNlEk8KDFB1Ymxpc2hGcmFtZRIeLmZyYW1lcy52MS5QdWJsaXNoRnJhbWVSZXF1ZXN0Gh8uZnJhbWVzLnYxLlB1Ymxpc2hGcmFtZVJlc3BvbnNlEkkKCkxpc3RGcmFtZXMSHC5mcmFtZXMudjEuTGlzdEZyYW1lc1JlcXVlc3QaHS5mcmFtZXMudjEuTGlzdEZyYW1lc1Jlc3BvbnNlEkMKCEdldEZyYW1lEhouZnJhbWVzLnYxLkdldEZyYW1lUmVxdWVzdBobLmZyYW1lcy52MS5HZXRGcmFtZVJlc3BvbnNlEk8KDFJlc29sdmVGcmFtZRIeLmZyYW1lcy52MS5SZXNvbHZlRnJhbWVSZXF1ZXN0Gh8uZnJhbWVzLnYxLlJlc29sdmVGcmFtZVJlc3BvbnNlEjoKBUdldE1lEhcuZnJhbWVzLnYxLkdldE1lUmVxdWVzdBoYLmZyYW1lcy52MS5HZXRNZVJlc3BvbnNlEl4KEUxpc3RGcmFtZVZlcnNpb25zEiMuZnJhbWVzLnYxLkxpc3RGcmFtZVZlcnNpb25zUmVxdWVzdBokLmZyYW1lcy52MS5MaXN0RnJhbWVWZXJzaW9uc1Jlc3BvbnNlEkwKC0RlbGV0ZUZyYW1lEh0uZnJhbWVzLnYxLkRlbGV0ZUZyYW1lUmVxdWVzdBoeLmZyYW1lcy52MS5EZWxldGVGcmFtZVJlc3BvbnNlEk8KDENvbnZlcnRGcmFtZRIeLmZyYW1lcy52MS5Db252ZXJ0RnJhbWVSZXF1ZXN0Gh8uZnJhbWVzLnYxLkNvbnZlcnRGcmFtZVJlc3BvbnNlEmEKEkxpc3RGcmFtZVRlbXBsYXRlcxIkLmZyYW1lcy52MS5MaXN0RnJhbWVUZW1wbGF0ZXNSZXF1ZXN0GiUuZnJhbWVzLnYxLkxpc3RGcmFtZVRlbXBsYXRlc1Jlc3BvbnNlElsKEEdldEZyYW1lVGVtcGxhdGUSIi5mcmFtZXMudjEuR2V0RnJhbWVUZW1wbGF0ZVJlcXVlc3QaIy5mcmFtZXMudjEuR2V0RnJhbWVUZW1wbGF0ZVJlc3BvbnNlEmQKE0NyZWF0ZUZyYW1lVGVtcGxhdGUSJS5mcmFtZXMudjEuQ3JlYXRlRnJhbWVUZW1wbGF0ZVJlcXVlc3QaJi5mcmFtZXMudjEuQ3JlYXRlRnJhbWVUZW1wbGF0ZVJlc3BvbnNlEmQKE1VwZGF0ZUZyYW1lVGVtcGxhdGUSJS5mcmFtZXMudjEuVXBkYXRlRnJhbWVUZW1wbGF0ZVJlcXVlc3QaJi5mcmFtZXMudjEuVXBkYXRlRnJhbWVUZW1wbGF0ZVJlc3BvbnNlEmQKE0RlbGV0ZUZyYW1lVGVtcGxhdGUSJS5mcmFtZXMudjEuRGVsZXRlRnJhbWVUZW1wbGF0ZVJlcXVlc3QaJi5mcmFtZXMudjEuRGVsZXRlRnJhbWVUZW1wbGF0ZVJlc3BvbnNlElUKDkxpc3RPcmdNZW1iZXJzEiAuZnJhbWVzLnYxLkxpc3RPcmdNZW1iZXJzUmVxdWVzdBohLmZyYW1lcy52MS5MaXN0T3JnTWVtYmVyc1Jlc3BvbnNlEk8KDEFkZE9yZ01lbWJlchIeLmZyYW1lcy52MS5BZGRPcmdNZW1iZXJSZXF1ZXN0Gh8uZnJhbWVzLnYxLkFkZE9yZ01lbWJlclJlc3BvbnNlElIKDVNldE1lbWJlclJvbGUSHy5mcmFtZXMudjEuU2V0TWVtYmVyUm9sZVJlcXVlc3QaIC5mcmFtZXMudjEuU2V0TWVtYmVyUm9sZVJlc3BvbnNlElgKD1JlbW92ZU9yZ01lbWJlchIhLmZyYW1lcy52MS5SZW1vdmVPcmdNZW1iZXJSZXF1ZXN0GiIuZnJhbWVzLnYxLlJlbW92ZU9yZ01lbWJlclJlc3BvbnNlQqYBCg1jb20uZnJhbWVzLnYxQhFGcmFtZVNlcnZpY2VQcm90b1ABWj1naXRodWIuY29tL25lYmFyaS1kZXYvbmViYXJpLWZyYW1lcy9nZW4vZ28vZnJhbWVzL3YxO2ZyYW1lc3YxogIDRlhYqgIJRnJhbWVzLlYxygIJRnJhbWVzXFYx4gIVRnJhbWVzXFYxXEdQQk1ldGFkYXRh6gIKRnJhbWVzOjpWMWIGcHJvdG8z", [file_frames_v1_frame]);
/**
* @generated from message frames.v1.PublishFrameRequest
@@ -29,6 +29,16 @@ export type PublishFrameRequest = Message<"frames.v1.PublishFrameRequest"> & {
* @generated from field: string changelog = 2;
*/
changelog: string;
+
+ /**
+ * Optional. Names the template this Frame is being created from, so its
+ * required slots are checked. Used for validation and then discarded:
+ * nothing is recorded on the Frame, and the check applies on create only.
+ * Empty means no template and no checks.
+ *
+ * @generated from field: string template_id = 3;
+ */
+ templateId: string;
};
/**
@@ -621,6 +631,211 @@ export type RemoveOrgMemberResponse = Message<"frames.v1.RemoveOrgMemberResponse
export const RemoveOrgMemberResponseSchema: GenMessage = /*@__PURE__*/
messageDesc(file_frames_v1_frame_service, 26);
+/**
+ * @generated from message frames.v1.ListFrameTemplatesRequest
+ */
+export type ListFrameTemplatesRequest = Message<"frames.v1.ListFrameTemplatesRequest"> & {
+};
+
+/**
+ * Describes the message frames.v1.ListFrameTemplatesRequest.
+ * Use `create(ListFrameTemplatesRequestSchema)` to create a new message.
+ */
+export const ListFrameTemplatesRequestSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_frames_v1_frame_service, 27);
+
+/**
+ * @generated from message frames.v1.ListFrameTemplatesResponse
+ */
+export type ListFrameTemplatesResponse = Message<"frames.v1.ListFrameTemplatesResponse"> & {
+ /**
+ * @generated from field: repeated frames.v1.FrameTemplateSummary templates = 1;
+ */
+ templates: FrameTemplateSummary[];
+
+ /**
+ * Whether the caller may create, edit, and delete org templates, so the SPA
+ * does not have to infer admin-ness itself. Mirrors ListFramesResponse.can_create.
+ *
+ * @generated from field: bool can_manage = 2;
+ */
+ canManage: boolean;
+};
+
+/**
+ * Describes the message frames.v1.ListFrameTemplatesResponse.
+ * Use `create(ListFrameTemplatesResponseSchema)` to create a new message.
+ */
+export const ListFrameTemplatesResponseSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_frames_v1_frame_service, 28);
+
+/**
+ * @generated from message frames.v1.GetFrameTemplateRequest
+ */
+export type GetFrameTemplateRequest = Message<"frames.v1.GetFrameTemplateRequest"> & {
+ /**
+ * @generated from field: string id = 1;
+ */
+ id: string;
+};
+
+/**
+ * Describes the message frames.v1.GetFrameTemplateRequest.
+ * Use `create(GetFrameTemplateRequestSchema)` to create a new message.
+ */
+export const GetFrameTemplateRequestSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_frames_v1_frame_service, 29);
+
+/**
+ * @generated from message frames.v1.GetFrameTemplateResponse
+ */
+export type GetFrameTemplateResponse = Message<"frames.v1.GetFrameTemplateResponse"> & {
+ /**
+ * @generated from field: frames.v1.FrameTemplate template = 1;
+ */
+ template?: FrameTemplate | undefined;
+};
+
+/**
+ * Describes the message frames.v1.GetFrameTemplateResponse.
+ * Use `create(GetFrameTemplateResponseSchema)` to create a new message.
+ */
+export const GetFrameTemplateResponseSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_frames_v1_frame_service, 30);
+
+/**
+ * @generated from message frames.v1.CreateFrameTemplateRequest
+ */
+export type CreateFrameTemplateRequest = Message<"frames.v1.CreateFrameTemplateRequest"> & {
+ /**
+ * @generated from field: string title = 1;
+ */
+ title: string;
+
+ /**
+ * @generated from field: string description = 2;
+ */
+ description: string;
+
+ /**
+ * @generated from field: bytes prefill = 3;
+ */
+ prefill: Uint8Array;
+
+ /**
+ * @generated from field: map field_rules = 4;
+ */
+ fieldRules: { [key: string]: FieldRule };
+};
+
+/**
+ * Describes the message frames.v1.CreateFrameTemplateRequest.
+ * Use `create(CreateFrameTemplateRequestSchema)` to create a new message.
+ */
+export const CreateFrameTemplateRequestSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_frames_v1_frame_service, 31);
+
+/**
+ * @generated from message frames.v1.CreateFrameTemplateResponse
+ */
+export type CreateFrameTemplateResponse = Message<"frames.v1.CreateFrameTemplateResponse"> & {
+ /**
+ * @generated from field: frames.v1.FrameTemplate template = 1;
+ */
+ template?: FrameTemplate | undefined;
+};
+
+/**
+ * Describes the message frames.v1.CreateFrameTemplateResponse.
+ * Use `create(CreateFrameTemplateResponseSchema)` to create a new message.
+ */
+export const CreateFrameTemplateResponseSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_frames_v1_frame_service, 32);
+
+/**
+ * @generated from message frames.v1.UpdateFrameTemplateRequest
+ */
+export type UpdateFrameTemplateRequest = Message<"frames.v1.UpdateFrameTemplateRequest"> & {
+ /**
+ * @generated from field: string id = 1;
+ */
+ id: string;
+
+ /**
+ * @generated from field: string title = 2;
+ */
+ title: string;
+
+ /**
+ * @generated from field: string description = 3;
+ */
+ description: string;
+
+ /**
+ * @generated from field: bytes prefill = 4;
+ */
+ prefill: Uint8Array;
+
+ /**
+ * @generated from field: map field_rules = 5;
+ */
+ fieldRules: { [key: string]: FieldRule };
+};
+
+/**
+ * Describes the message frames.v1.UpdateFrameTemplateRequest.
+ * Use `create(UpdateFrameTemplateRequestSchema)` to create a new message.
+ */
+export const UpdateFrameTemplateRequestSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_frames_v1_frame_service, 33);
+
+/**
+ * @generated from message frames.v1.UpdateFrameTemplateResponse
+ */
+export type UpdateFrameTemplateResponse = Message<"frames.v1.UpdateFrameTemplateResponse"> & {
+ /**
+ * @generated from field: frames.v1.FrameTemplate template = 1;
+ */
+ template?: FrameTemplate | undefined;
+};
+
+/**
+ * Describes the message frames.v1.UpdateFrameTemplateResponse.
+ * Use `create(UpdateFrameTemplateResponseSchema)` to create a new message.
+ */
+export const UpdateFrameTemplateResponseSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_frames_v1_frame_service, 34);
+
+/**
+ * @generated from message frames.v1.DeleteFrameTemplateRequest
+ */
+export type DeleteFrameTemplateRequest = Message<"frames.v1.DeleteFrameTemplateRequest"> & {
+ /**
+ * @generated from field: string id = 1;
+ */
+ id: string;
+};
+
+/**
+ * Describes the message frames.v1.DeleteFrameTemplateRequest.
+ * Use `create(DeleteFrameTemplateRequestSchema)` to create a new message.
+ */
+export const DeleteFrameTemplateRequestSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_frames_v1_frame_service, 35);
+
+/**
+ * @generated from message frames.v1.DeleteFrameTemplateResponse
+ */
+export type DeleteFrameTemplateResponse = Message<"frames.v1.DeleteFrameTemplateResponse"> & {
+};
+
+/**
+ * Describes the message frames.v1.DeleteFrameTemplateResponse.
+ * Use `create(DeleteFrameTemplateResponseSchema)` to create a new message.
+ */
+export const DeleteFrameTemplateResponseSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_frames_v1_frame_service, 36);
+
/**
* @generated from service frames.v1.FrameService
*/
@@ -706,6 +921,60 @@ export const FrameService: GenService<{
input: typeof ConvertFrameRequestSchema;
output: typeof ConvertFrameResponseSchema;
},
+ /**
+ * Read - the templates the caller may start a Frame from: the built-ins plus
+ * their own org's. Any member may call it.
+ *
+ * @generated from rpc frames.v1.FrameService.ListFrameTemplates
+ */
+ listFrameTemplates: {
+ methodKind: "unary";
+ input: typeof ListFrameTemplatesRequestSchema;
+ output: typeof ListFrameTemplatesResponseSchema;
+ },
+ /**
+ * Read - one template with its prefill and rules. 404 for a built-in that
+ * does not exist and for another org's template alike, so existence does not
+ * leak.
+ *
+ * @generated from rpc frames.v1.FrameService.GetFrameTemplate
+ */
+ getFrameTemplate: {
+ methodKind: "unary";
+ input: typeof GetFrameTemplateRequestSchema;
+ output: typeof GetFrameTemplateResponseSchema;
+ },
+ /**
+ * Admin only - create an org template.
+ *
+ * @generated from rpc frames.v1.FrameService.CreateFrameTemplate
+ */
+ createFrameTemplate: {
+ methodKind: "unary";
+ input: typeof CreateFrameTemplateRequestSchema;
+ output: typeof CreateFrameTemplateResponseSchema;
+ },
+ /**
+ * Admin only - replace an org template's content. Built-ins are immutable.
+ *
+ * @generated from rpc frames.v1.FrameService.UpdateFrameTemplate
+ */
+ updateFrameTemplate: {
+ methodKind: "unary";
+ input: typeof UpdateFrameTemplateRequestSchema;
+ output: typeof UpdateFrameTemplateResponseSchema;
+ },
+ /**
+ * Admin only - delete an org template. Frames already created from it are
+ * unaffected: a template is copied once and never referenced again.
+ *
+ * @generated from rpc frames.v1.FrameService.DeleteFrameTemplate
+ */
+ deleteFrameTemplate: {
+ methodKind: "unary";
+ input: typeof DeleteFrameTemplateRequestSchema;
+ output: typeof DeleteFrameTemplateResponseSchema;
+ },
/**
* Admin only - list the caller's org members.
*
diff --git a/proto/frames/v1/frame.proto b/proto/frames/v1/frame.proto
index 6aeb4d5..b5faede 100644
--- a/proto/frames/v1/frame.proto
+++ b/proto/frames/v1/frame.proto
@@ -67,3 +67,48 @@ message FrameVersionSummary {
string published_by = 3;
google.protobuf.Timestamp published_at = 4;
}
+
+// Requirement is how strongly a template wants a slot filled. Stricter than the
+// Frame schema, which requires no slot at all: an org uses it to hold its own
+// Frames to a house standard the spec does not impose.
+enum Requirement {
+ REQUIREMENT_UNSPECIFIED = 0; // treated as optional
+ REQUIREMENT_OPTIONAL = 1;
+ REQUIREMENT_RECOMMENDED = 2;
+ REQUIREMENT_REQUIRED = 3;
+}
+
+// FieldRule is a template's expectation for one slot. `note` is the template
+// author's own guidance on what belongs there: the seeded authoring form shows
+// it in place of the generic per-slot hint, and an MCP client asks it as a
+// question. A message rather than a bare Requirement because widening a map's
+// value type later would be a breaking wire change.
+message FieldRule {
+ Requirement level = 1;
+ string note = 2;
+}
+
+// FrameTemplateSummary is what a picker needs: no prefill, so listing stays
+// cheap. `builtin` is computed server-side so no client has to parse an id.
+message FrameTemplateSummary {
+ string id = 1;
+ string title = 2;
+ string description = 3;
+ bool builtin = 4;
+}
+
+message FrameTemplate {
+ string id = 1;
+ string title = 2;
+ string description = 3;
+ bool builtin = 4;
+ // Canonical YAML carrying only `slots` and `extends`. Bytes rather than a
+ // structured message on purpose: a message mirroring the slot schema would be
+ // a second place to update whenever frames.SlotTable changes, which is what
+ // the reflective guards in the MCP tests exist to catch.
+ bytes prefill = 5;
+ // Slot key (terminology, rules, skills, prompts, tool_specs, goals, style,
+ // norms, architecture, business_process) -> rule. Keyed by string, so adding
+ // a slot does not change this shape either.
+ map field_rules = 6;
+}
diff --git a/proto/frames/v1/frame_service.proto b/proto/frames/v1/frame_service.proto
index b4a9f3c..43f9e18 100644
--- a/proto/frames/v1/frame_service.proto
+++ b/proto/frames/v1/frame_service.proto
@@ -23,6 +23,20 @@ service FrameService {
// Pure conversion between the canonical slot YAML and the spec-conformant
// .frame.md form. Stateless and unauthenticated beyond org membership.
rpc ConvertFrame(ConvertFrameRequest) returns (ConvertFrameResponse);
+ // Read - the templates the caller may start a Frame from: the built-ins plus
+ // their own org's. Any member may call it.
+ rpc ListFrameTemplates(ListFrameTemplatesRequest) returns (ListFrameTemplatesResponse);
+ // Read - one template with its prefill and rules. 404 for a built-in that
+ // does not exist and for another org's template alike, so existence does not
+ // leak.
+ rpc GetFrameTemplate(GetFrameTemplateRequest) returns (GetFrameTemplateResponse);
+ // Admin only - create an org template.
+ rpc CreateFrameTemplate(CreateFrameTemplateRequest) returns (CreateFrameTemplateResponse);
+ // Admin only - replace an org template's content. Built-ins are immutable.
+ rpc UpdateFrameTemplate(UpdateFrameTemplateRequest) returns (UpdateFrameTemplateResponse);
+ // Admin only - delete an org template. Frames already created from it are
+ // unaffected: a template is copied once and never referenced again.
+ rpc DeleteFrameTemplate(DeleteFrameTemplateRequest) returns (DeleteFrameTemplateResponse);
// Admin only - list the caller's org members.
rpc ListOrgMembers(ListOrgMembersRequest) returns (ListOrgMembersResponse);
// Admin only - add a member to the caller's org by email (pending until login).
@@ -36,6 +50,11 @@ service FrameService {
message PublishFrameRequest {
bytes content = 1; // full YAML; name/version/extends/excludes/slots parsed server-side
string changelog = 2;
+ // Optional. Names the template this Frame is being created from, so its
+ // required slots are checked. Used for validation and then discarded:
+ // nothing is recorded on the Frame, and the check applies on create only.
+ // Empty means no template and no checks.
+ string template_id = 3;
}
message PublishFrameResponse {
Frame frame = 1;
@@ -157,3 +176,34 @@ message RemoveOrgMemberRequest {
string email = 2;
}
message RemoveOrgMemberResponse {}
+
+message ListFrameTemplatesRequest {}
+message ListFrameTemplatesResponse {
+ repeated FrameTemplateSummary templates = 1;
+ // Whether the caller may create, edit, and delete org templates, so the SPA
+ // does not have to infer admin-ness itself. Mirrors ListFramesResponse.can_create.
+ bool can_manage = 2;
+}
+
+message GetFrameTemplateRequest { string id = 1; }
+message GetFrameTemplateResponse { FrameTemplate template = 1; }
+
+message CreateFrameTemplateRequest {
+ string title = 1;
+ string description = 2;
+ bytes prefill = 3;
+ map field_rules = 4;
+}
+message CreateFrameTemplateResponse { FrameTemplate template = 1; }
+
+message UpdateFrameTemplateRequest {
+ string id = 1;
+ string title = 2;
+ string description = 3;
+ bytes prefill = 4;
+ map field_rules = 5;
+}
+message UpdateFrameTemplateResponse { FrameTemplate template = 1; }
+
+message DeleteFrameTemplateRequest { string id = 1; }
+message DeleteFrameTemplateResponse {}
From 06c5083dc4a1d05259ff5e67420a40600319a21f Mon Sep 17 00:00:00 2001
From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com>
Date: Mon, 7 Sep 2026 12:46:31 +0200
Subject: [PATCH 08/43] feat(frames): serve Frame templates over
ListFrameTemplates and GetFrameTemplate
---
backend/internal/frames/templates.go | 50 ++++
backend/internal/frames/templates_service.go | 177 ++++++++++++
.../internal/frames/templates_service_test.go | 251 ++++++++++++++++++
3 files changed, 478 insertions(+)
create mode 100644 backend/internal/frames/templates_service.go
create mode 100644 backend/internal/frames/templates_service_test.go
diff --git a/backend/internal/frames/templates.go b/backend/internal/frames/templates.go
index 9a06c8d..3fd6685 100644
--- a/backend/internal/frames/templates.go
+++ b/backend/internal/frames/templates.go
@@ -2,6 +2,7 @@ package frames
import (
"bytes"
+ "encoding/json"
"fmt"
"strings"
@@ -237,6 +238,55 @@ func MarshalPrefill(p Prefill) ([]byte, error) {
return yaml.Marshal(prefillDoc{Extends: p.Extends, Slots: p.Slots})
}
+// Field rules are persisted as JSON with the level written by name, never as the
+// enum's integer: reordering the Requirement constants must not silently
+// reinterpret every row already on disk. This is also why the wire enum has an
+// explicit UNSPECIFIED = 0.
+
+type fieldRuleJSON struct {
+ Level string `json:"level"`
+ Note string `json:"note,omitempty"`
+}
+
+// MarshalFieldRules encodes rules for storage in frame_templates.field_rules.
+func MarshalFieldRules(rules map[string]FieldRule) ([]byte, error) {
+ out := make(map[string]fieldRuleJSON, len(rules))
+ for key, rule := range rules {
+ out[key] = fieldRuleJSON{Level: rule.Level.String(), Note: rule.Note}
+ }
+ return json.Marshal(out)
+}
+
+// ParseFieldRules decodes a stored field_rules blob. An unknown level is an
+// error rather than a fall back to optional, for the same reason the YAML
+// decoder refuses one: a requirement that quietly stops applying is worse than
+// a loud failure.
+func ParseFieldRules(b []byte) (map[string]FieldRule, error) {
+ if len(b) == 0 {
+ return map[string]FieldRule{}, nil
+ }
+ var raw map[string]fieldRuleJSON
+ if err := json.Unmarshal(b, &raw); err != nil {
+ return nil, fmt.Errorf("parse field rules: %w", err)
+ }
+ out := make(map[string]FieldRule, len(raw))
+ for key, rule := range raw {
+ var level Requirement
+ switch strings.ToLower(strings.TrimSpace(rule.Level)) {
+ case "", "optional":
+ level = RequirementOptional
+ case "recommended":
+ level = RequirementRecommended
+ case "required":
+ level = RequirementRequired
+ default:
+ return nil, fmt.Errorf("unknown requirement level %q for slot %q", rule.Level, key)
+ }
+ out[key] = FieldRule{Level: level, Note: rule.Note}
+ }
+ return out, nil
+}
+
// unmarshalStrict decodes YAML into v, rejecting unknown keys. Template
// definition files are ours and their key set is fixed, so a typo must fail
// loudly rather than leave a rule silently unset.
diff --git a/backend/internal/frames/templates_service.go b/backend/internal/frames/templates_service.go
new file mode 100644
index 0000000..b89c5fc
--- /dev/null
+++ b/backend/internal/frames/templates_service.go
@@ -0,0 +1,177 @@
+package frames
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "connectrpc.com/connect"
+
+ "github.com/nebari-dev/nebari-frames/backend/internal/rbac"
+ "github.com/nebari-dev/nebari-frames/backend/internal/store"
+ framesv1 "github.com/nebari-dev/nebari-frames/gen/go/frames/v1"
+)
+
+// The five template RPCs. Kept out of service.go, which is already large,
+// following the precedent of admin.go.
+//
+// Templates are an authoring affordance, not Frames, so there are no per-item
+// grants: org membership is the only access control, and it is enforced by
+// passing the caller's org into every store accessor. Managing templates is
+// admin-only because a template is an org standard, which puts it beside member
+// management rather than beside Frame authoring.
+
+// requirementToProto converts the level for the wire. The proto enum has an
+// explicit UNSPECIFIED that maps to optional, so an older client that leaves
+// the field unset gets the permissive reading rather than an accidental
+// requirement. The reverse direction belongs to the write handlers in Task 8,
+// which is the only place anything decodes a caller-supplied level.
+func requirementToProto(r Requirement) framesv1.Requirement {
+ switch r {
+ case RequirementRecommended:
+ return framesv1.Requirement_REQUIREMENT_RECOMMENDED
+ case RequirementRequired:
+ return framesv1.Requirement_REQUIREMENT_REQUIRED
+ default:
+ return framesv1.Requirement_REQUIREMENT_OPTIONAL
+ }
+}
+
+func fieldRulesToProto(rules map[string]FieldRule) map[string]*framesv1.FieldRule {
+ if len(rules) == 0 {
+ return nil
+ }
+ out := make(map[string]*framesv1.FieldRule, len(rules))
+ for key, rule := range rules {
+ out[key] = &framesv1.FieldRule{Level: requirementToProto(rule.Level), Note: rule.Note}
+ }
+ return out
+}
+
+func templateSummaryToProto(t *Template) *framesv1.FrameTemplateSummary {
+ return &framesv1.FrameTemplateSummary{
+ Id: t.ID, Title: t.Title, Description: t.Description, Builtin: IsBuiltin(t.ID),
+ }
+}
+
+func templateToProto(t *Template) (*framesv1.FrameTemplate, error) {
+ prefill, err := MarshalPrefill(t.Prefill)
+ if err != nil {
+ return nil, err
+ }
+ return &framesv1.FrameTemplate{
+ Id: t.ID, Title: t.Title, Description: t.Description, Builtin: IsBuiltin(t.ID),
+ Prefill: prefill, FieldRules: fieldRulesToProto(t.FieldRules),
+ }, nil
+}
+
+// rowToTemplate decodes a stored row into the internal model.
+func rowToTemplate(row *store.FrameTemplate) (*Template, error) {
+ prefill, err := ParsePrefill(row.Prefill)
+ if err != nil {
+ return nil, fmt.Errorf("template %s: %w", row.ID, err)
+ }
+ rules, err := ParseFieldRules(row.FieldRules)
+ if err != nil {
+ return nil, fmt.Errorf("template %s: %w", row.ID, err)
+ }
+ return &Template{
+ ID: row.ID, Title: row.Title, Description: row.Description,
+ Prefill: prefill, FieldRules: rules,
+ }, nil
+}
+
+// lookupTemplate resolves an id to a template the caller may use: a built-in, or
+// a row in the caller's own org. Everything else, including another org's row,
+// reports NotFound so existence does not leak - the same rule frames follow.
+func (s *Service) lookupTemplate(ctx context.Context, caller rbac.Caller, id string) (*Template, error) {
+ if id == "" {
+ return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("id is required"))
+ }
+ if IsBuiltin(id) {
+ tmpl, ok := BuiltinTemplate(id)
+ if !ok {
+ return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("no template %q", id))
+ }
+ return &tmpl, nil
+ }
+ row, err := s.repo.GetFrameTemplate(ctx, caller.OrgID, id)
+ if errors.Is(err, store.ErrNotFound) {
+ return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("no template %q", id))
+ }
+ if err != nil {
+ return nil, connect.NewError(connect.CodeInternal, err)
+ }
+ tmpl, err := rowToTemplate(row)
+ if err != nil {
+ // A row this server wrote failed to decode, so the data is corrupt
+ // rather than the request wrong.
+ return nil, connect.NewError(connect.CodeInternal, err)
+ }
+ return tmpl, nil
+}
+
+func (s *Service) ListFrameTemplates(ctx context.Context, _ *connect.Request[framesv1.ListFrameTemplatesRequest]) (*connect.Response[framesv1.ListFrameTemplatesResponse], error) {
+ caller, err := s.resolveCaller(ctx)
+ if err != nil {
+ return nil, err
+ }
+ rows, err := s.repo.ListFrameTemplatesByOrg(ctx, caller.OrgID)
+ if err != nil {
+ return nil, connect.NewError(connect.CodeInternal, err)
+ }
+ // Built-ins first (Blank leads), then the org's own. No shadowing: an org
+ // template sharing a built-in's title sits beside it rather than replacing
+ // it, because a precedence rule here would be a permanent source of "why am
+ // I seeing the wrong one". The UI groups them instead.
+ out := make([]*framesv1.FrameTemplateSummary, 0, len(rows)+6)
+ for _, tmpl := range BuiltinTemplates() {
+ out = append(out, templateSummaryToProto(&tmpl))
+ }
+ for _, row := range rows {
+ out = append(out, &framesv1.FrameTemplateSummary{
+ Id: row.ID, Title: row.Title, Description: row.Description, Builtin: false,
+ })
+ }
+ return connect.NewResponse(&framesv1.ListFrameTemplatesResponse{
+ Templates: out,
+ CanManage: caller.Role == rbac.RoleAdmin,
+ }), nil
+}
+
+func (s *Service) GetFrameTemplate(ctx context.Context, req *connect.Request[framesv1.GetFrameTemplateRequest]) (*connect.Response[framesv1.GetFrameTemplateResponse], error) {
+ caller, err := s.resolveCaller(ctx)
+ if err != nil {
+ return nil, err
+ }
+ tmpl, err := s.lookupTemplate(ctx, caller, req.Msg.Id)
+ if err != nil {
+ return nil, err
+ }
+ pb, err := templateToProto(tmpl)
+ if err != nil {
+ return nil, connect.NewError(connect.CodeInternal, err)
+ }
+ return connect.NewResponse(&framesv1.GetFrameTemplateResponse{Template: pb}), nil
+}
+
+// TEMPORARY (Task 7): placeholder so the FrameServiceHandler assertion in service.go
+// compiles. Task 8 replaces this with the real handler. If you are reading this in
+// merged code, it escaped review.
+func (s *Service) CreateFrameTemplate(context.Context, *connect.Request[framesv1.CreateFrameTemplateRequest]) (*connect.Response[framesv1.CreateFrameTemplateResponse], error) {
+ return nil, connect.NewError(connect.CodeUnimplemented, errors.New("not implemented until Task 8"))
+}
+
+// TEMPORARY (Task 7): placeholder so the FrameServiceHandler assertion in service.go
+// compiles. Task 8 replaces this with the real handler. If you are reading this in
+// merged code, it escaped review.
+func (s *Service) UpdateFrameTemplate(context.Context, *connect.Request[framesv1.UpdateFrameTemplateRequest]) (*connect.Response[framesv1.UpdateFrameTemplateResponse], error) {
+ return nil, connect.NewError(connect.CodeUnimplemented, errors.New("not implemented until Task 8"))
+}
+
+// TEMPORARY (Task 7): placeholder so the FrameServiceHandler assertion in service.go
+// compiles. Task 8 replaces this with the real handler. If you are reading this in
+// merged code, it escaped review.
+func (s *Service) DeleteFrameTemplate(context.Context, *connect.Request[framesv1.DeleteFrameTemplateRequest]) (*connect.Response[framesv1.DeleteFrameTemplateResponse], error) {
+ return nil, connect.NewError(connect.CodeUnimplemented, errors.New("not implemented until Task 8"))
+}
diff --git a/backend/internal/frames/templates_service_test.go b/backend/internal/frames/templates_service_test.go
new file mode 100644
index 0000000..7ae1ca0
--- /dev/null
+++ b/backend/internal/frames/templates_service_test.go
@@ -0,0 +1,251 @@
+package frames
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ "connectrpc.com/connect"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ "github.com/nebari-dev/nebari-frames/backend/internal/auth"
+ "github.com/nebari-dev/nebari-frames/backend/internal/store"
+ framesv1 "github.com/nebari-dev/nebari-frames/gen/go/frames/v1"
+)
+
+// templateFixture builds a service with two orgs and a caller in each, so
+// org-scoping assertions always have a control that must succeed.
+type templateFixture struct {
+ svc *Service
+ repo *store.Memory
+}
+
+func newTemplateFixture(t *testing.T, role string) (templateFixture, context.Context) {
+ t.Helper()
+ ctx := context.Background()
+ repo := store.NewMemory()
+ for _, spec := range []struct{ id, slug string }{{"org-a", "acme"}, {"org-b", "other"}} {
+ if err := repo.CreateOrg(ctx, &framesv1.Org{
+ Id: spec.id, Slug: spec.slug, DisplayName: spec.slug, CreatedAt: timestamppb.Now(),
+ }); err != nil {
+ t.Fatalf("create org %s: %v", spec.id, err)
+ }
+ }
+ if err := repo.UpsertMembership(ctx, &framesv1.Membership{
+ OrgId: "org-a", UserSub: "user-a", Role: role, AddedAt: timestamppb.Now(),
+ }); err != nil {
+ t.Fatalf("membership: %v", err)
+ }
+ callerCtx := auth.WithClaims(ctx, &auth.Claims{Subject: "user-a", Email: "a@example.com"})
+ return templateFixture{svc: NewService(repo), repo: repo}, callerCtx
+}
+
+func seedOrgTemplate(t *testing.T, repo *store.Memory, id, orgID, title string, rules map[string]FieldRule) {
+ t.Helper()
+ rulesJSON, err := MarshalFieldRules(rules)
+ if err != nil {
+ t.Fatalf("marshal rules: %v", err)
+ }
+ prefill, err := MarshalPrefill(Prefill{Slots: Slots{Style: "Plain sentences."}})
+ if err != nil {
+ t.Fatalf("marshal prefill: %v", err)
+ }
+ now := time.Unix(1700000000, 0).UTC()
+ if err := repo.CreateFrameTemplate(context.Background(), &store.FrameTemplate{
+ ID: id, OrgID: orgID, Title: title, Description: "seeded",
+ Prefill: prefill, FieldRules: rulesJSON, CreatedBy: "user-a",
+ CreatedAt: now, UpdatedAt: now,
+ }); err != nil {
+ t.Fatalf("seed template %s: %v", id, err)
+ }
+}
+
+func TestListFrameTemplatesIncludesBuiltinsAndOrgRows(t *testing.T) {
+ f, ctx := newTemplateFixture(t, "admin")
+ seedOrgTemplate(t, f.repo, "t1", "org-a", "Our House Style", nil)
+ seedOrgTemplate(t, f.repo, "t2", "org-b", "Not Yours", nil)
+
+ resp, err := f.svc.ListFrameTemplates(ctx, connect.NewRequest(&framesv1.ListFrameTemplatesRequest{}))
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ byID := map[string]*framesv1.FrameTemplateSummary{}
+ for _, s := range resp.Msg.Templates {
+ byID[s.Id] = s
+ }
+ // Built-ins come first, Blank leading, so the picker is stable.
+ if len(resp.Msg.Templates) == 0 || resp.Msg.Templates[0].Id != BlankTemplateID {
+ t.Fatalf("first entry = %v, want %q", resp.Msg.Templates, BlankTemplateID)
+ }
+ if !byID[BlankTemplateID].Builtin {
+ t.Error("blank is not flagged builtin")
+ }
+ own, ok := byID["t1"]
+ if !ok {
+ t.Fatal("the caller's own org template is missing")
+ }
+ if own.Builtin {
+ t.Error("an org template is flagged builtin")
+ }
+ if own.Title != "Our House Style" {
+ t.Errorf("title = %q", own.Title)
+ }
+ // The other org's row must not appear. This is the assertion that matters;
+ // the presence checks above are its control.
+ if _, leaked := byID["t2"]; leaked {
+ t.Error("another org's template appeared in the list")
+ }
+ if !resp.Msg.CanManage {
+ t.Error("can_manage = false for an admin")
+ }
+}
+
+func TestListFrameTemplatesCanManageFollowsRole(t *testing.T) {
+ tests := []struct {
+ role string
+ wantCanManage bool
+ }{
+ {role: "admin", wantCanManage: true},
+ {role: "publisher", wantCanManage: false},
+ {role: "viewer", wantCanManage: false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.role, func(t *testing.T) {
+ f, ctx := newTemplateFixture(t, tt.role)
+ resp, err := f.svc.ListFrameTemplates(ctx, connect.NewRequest(&framesv1.ListFrameTemplatesRequest{}))
+ if err != nil {
+ t.Fatalf("list as %s: %v", tt.role, err)
+ }
+ if resp.Msg.CanManage != tt.wantCanManage {
+ t.Errorf("can_manage = %v, want %v", resp.Msg.CanManage, tt.wantCanManage)
+ }
+ // Every role can still list: templates are readable to any member.
+ if len(resp.Msg.Templates) == 0 {
+ t.Error("no templates returned")
+ }
+ })
+ }
+}
+
+func TestGetFrameTemplate(t *testing.T) {
+ f, ctx := newTemplateFixture(t, "publisher")
+ seedOrgTemplate(t, f.repo, "t1", "org-a", "Our House Style", map[string]FieldRule{
+ "style": {Level: RequirementRequired, Note: "Voice and tone."},
+ })
+ seedOrgTemplate(t, f.repo, "t2", "org-b", "Not Yours", nil)
+
+ tests := []struct {
+ name string
+ id string
+ wantCode connect.Code
+ wantRule Requirement
+ wantNote string
+ }{
+ {name: "own org template", id: "t1", wantRule: RequirementRequired, wantNote: "Voice and tone."},
+ {name: "builtin", id: "builtin:domain-vocabulary", wantRule: RequirementRequired},
+ {name: "another org's template is not found", id: "t2", wantCode: connect.CodeNotFound},
+ {name: "missing builtin is not found", id: "builtin:nope", wantCode: connect.CodeNotFound},
+ {name: "unknown id is not found", id: "01J000000000000000000000AB", wantCode: connect.CodeNotFound},
+ {name: "empty id is invalid", id: "", wantCode: connect.CodeInvalidArgument},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ resp, err := f.svc.GetFrameTemplate(ctx, connect.NewRequest(&framesv1.GetFrameTemplateRequest{Id: tt.id}))
+ if tt.wantCode != 0 {
+ if connect.CodeOf(err) != tt.wantCode {
+ t.Fatalf("code = %v (err %v), want %v", connect.CodeOf(err), err, tt.wantCode)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ got := resp.Msg.Template
+ if got.Id != tt.id {
+ t.Errorf("id = %q, want %q", got.Id, tt.id)
+ }
+ if got.Builtin != IsBuiltin(tt.id) {
+ t.Errorf("builtin = %v, want %v", got.Builtin, IsBuiltin(tt.id))
+ }
+ if len(got.FieldRules) == 0 {
+ t.Fatal("no field rules returned")
+ }
+ if tt.wantNote != "" {
+ if got.FieldRules["style"].Note != tt.wantNote {
+ t.Errorf("note = %q, want %q", got.FieldRules["style"].Note, tt.wantNote)
+ }
+ }
+ // Prefill must come back as something ParsePrefill accepts, or the
+ // clients cannot seed a form from it.
+ if _, err := ParsePrefill(got.Prefill); err != nil {
+ t.Errorf("prefill is not parseable: %v\n%s", err, got.Prefill)
+ }
+ })
+ }
+}
+
+func TestFieldRulesJSONRoundTrip(t *testing.T) {
+ tests := []struct {
+ name string
+ rules map[string]FieldRule
+ }{
+ {name: "nil", rules: nil},
+ {name: "empty", rules: map[string]FieldRule{}},
+ {
+ name: "every level",
+ rules: map[string]FieldRule{
+ "style": {Level: RequirementRequired, Note: "Voice."},
+ "rules": {Level: RequirementRecommended, Note: "Constraints."},
+ "terminology": {Level: RequirementOptional},
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ b, err := MarshalFieldRules(tt.rules)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ got, err := ParseFieldRules(b)
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ if len(got) != len(tt.rules) {
+ t.Fatalf("got %d rules, want %d (%s)", len(got), len(tt.rules), b)
+ }
+ for key, want := range tt.rules {
+ if got[key] != want {
+ t.Errorf("rule %q = %+v, want %+v", key, got[key], want)
+ }
+ }
+ })
+ }
+}
+
+func TestFieldRulesJSONStoresLevelNames(t *testing.T) {
+ // Stored by name, never by the enum's integer: reordering the constants
+ // must not silently reinterpret every row already on disk.
+ b, err := MarshalFieldRules(map[string]FieldRule{"style": {Level: RequirementRequired}})
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if want := `"level":"required"`; !strings.Contains(string(b), want) {
+ t.Errorf("encoded as %s, want it to contain %s", b, want)
+ }
+}
+
+func TestParseFieldRulesRejectsUnknownLevel(t *testing.T) {
+ _, err := ParseFieldRules([]byte(`{"style":{"level":"mandatory"}}`))
+ if err == nil {
+ t.Fatal("an unknown level was accepted")
+ }
+ // Naming the offending value matters: this error reaches an admin editing a
+ // template, and "invalid input" would not tell them which level to fix.
+ if !strings.Contains(err.Error(), "unknown requirement level") {
+ t.Errorf("err = %v, want it to name the unknown level", err)
+ }
+ if !strings.Contains(err.Error(), "style") {
+ t.Errorf("err = %v, want it to name the slot", err)
+ }
+}
From 99ed6206faaa9ab9ae2472ffe39f421d5f0f4c23 Mon Sep 17 00:00:00 2001
From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com>
Date: Mon, 7 Sep 2026 12:54:21 +0200
Subject: [PATCH 09/43] feat(frames): let org admins create, edit, and delete
Frame templates
---
backend/internal/frames/templates_service.go | 187 ++++++++++++--
.../internal/frames/templates_service_test.go | 228 ++++++++++++++++++
cli/internal/testutil/stub_server.go | 52 +++-
3 files changed, 446 insertions(+), 21 deletions(-)
diff --git a/backend/internal/frames/templates_service.go b/backend/internal/frames/templates_service.go
index b89c5fc..a306512 100644
--- a/backend/internal/frames/templates_service.go
+++ b/backend/internal/frames/templates_service.go
@@ -4,6 +4,8 @@ import (
"context"
"errors"
"fmt"
+ "strings"
+ "time"
"connectrpc.com/connect"
@@ -155,23 +157,178 @@ func (s *Service) GetFrameTemplate(ctx context.Context, req *connect.Request[fra
return connect.NewResponse(&framesv1.GetFrameTemplateResponse{Template: pb}), nil
}
-// TEMPORARY (Task 7): placeholder so the FrameServiceHandler assertion in service.go
-// compiles. Task 8 replaces this with the real handler. If you are reading this in
-// merged code, it escaped review.
-func (s *Service) CreateFrameTemplate(context.Context, *connect.Request[framesv1.CreateFrameTemplateRequest]) (*connect.Response[framesv1.CreateFrameTemplateResponse], error) {
- return nil, connect.NewError(connect.CodeUnimplemented, errors.New("not implemented until Task 8"))
+// requirementFromProto reads the wire level. UNSPECIFIED maps to optional, so an
+// older client that leaves the field unset gets the permissive reading rather
+// than an accidental requirement.
+func requirementFromProto(r framesv1.Requirement) Requirement {
+ switch r {
+ case framesv1.Requirement_REQUIREMENT_RECOMMENDED:
+ return RequirementRecommended
+ case framesv1.Requirement_REQUIREMENT_REQUIRED:
+ return RequirementRequired
+ default:
+ return RequirementOptional
+ }
}
-// TEMPORARY (Task 7): placeholder so the FrameServiceHandler assertion in service.go
-// compiles. Task 8 replaces this with the real handler. If you are reading this in
-// merged code, it escaped review.
-func (s *Service) UpdateFrameTemplate(context.Context, *connect.Request[framesv1.UpdateFrameTemplateRequest]) (*connect.Response[framesv1.UpdateFrameTemplateResponse], error) {
- return nil, connect.NewError(connect.CodeUnimplemented, errors.New("not implemented until Task 8"))
+// fieldRulesFromProto converts and validates client-supplied rules. An unknown
+// slot key is refused: silently keeping a rule that can never fire would let an
+// admin believe they had imposed a standard they had not.
+func fieldRulesFromProto(in map[string]*framesv1.FieldRule) (map[string]FieldRule, error) {
+ out := make(map[string]FieldRule, len(in))
+ for key, rule := range in {
+ if _, ok := slotByKey(key); !ok {
+ return nil, fmt.Errorf("field_rules names unknown slot %q", key)
+ }
+ if rule == nil {
+ continue
+ }
+ out[key] = FieldRule{Level: requirementFromProto(rule.Level), Note: rule.Note}
+ }
+ return out, nil
+}
+
+// templateInput is the validated, decoded form of a create or update request.
+// Both RPCs take the same fields, so they share one validator: a rule that
+// applied on create but not on update would let an admin edit their way around
+// it.
+type templateInput struct {
+ title string
+ description string
+ prefill []byte
+ rules []byte
}
-// TEMPORARY (Task 7): placeholder so the FrameServiceHandler assertion in service.go
-// compiles. Task 8 replaces this with the real handler. If you are reading this in
-// merged code, it escaped review.
-func (s *Service) DeleteFrameTemplate(context.Context, *connect.Request[framesv1.DeleteFrameTemplateRequest]) (*connect.Response[framesv1.DeleteFrameTemplateResponse], error) {
- return nil, connect.NewError(connect.CodeUnimplemented, errors.New("not implemented until Task 8"))
+func validateTemplateInput(title, description string, prefill []byte, rules map[string]*framesv1.FieldRule) (templateInput, error) {
+ title = strings.TrimSpace(title)
+ description = strings.TrimSpace(description)
+ if title == "" {
+ return templateInput{}, connect.NewError(connect.CodeInvalidArgument, errors.New("title is required"))
+ }
+ if description == "" {
+ return templateInput{}, connect.NewError(connect.CodeInvalidArgument, errors.New("description is required"))
+ }
+ // An empty prefill is legitimate and means "seed no content", which is what
+ // every built-in does. Normalize it to a parseable document so the stored
+ // blob is always something ParsePrefill accepts.
+ if len(prefill) == 0 {
+ empty, err := MarshalPrefill(Prefill{})
+ if err != nil {
+ return templateInput{}, connect.NewError(connect.CodeInternal, err)
+ }
+ prefill = empty
+ } else if _, err := ParsePrefill(prefill); err != nil {
+ return templateInput{}, connect.NewError(connect.CodeInvalidArgument, err)
+ }
+ decoded, err := fieldRulesFromProto(rules)
+ if err != nil {
+ return templateInput{}, connect.NewError(connect.CodeInvalidArgument, err)
+ }
+ rulesJSON, err := MarshalFieldRules(decoded)
+ if err != nil {
+ return templateInput{}, connect.NewError(connect.CodeInternal, err)
+ }
+ return templateInput{title: title, description: description, prefill: prefill, rules: rulesJSON}, nil
+}
+
+// respondWithRow reads back what was written and returns it, so a caller always
+// sees the stored form rather than an optimistic echo of its own request.
+func (s *Service) respondWithRow(ctx context.Context, orgID, id string) (*framesv1.FrameTemplate, error) {
+ row, err := s.repo.GetFrameTemplate(ctx, orgID, id)
+ if err != nil {
+ return nil, connect.NewError(connect.CodeInternal, err)
+ }
+ tmpl, err := rowToTemplate(row)
+ if err != nil {
+ return nil, connect.NewError(connect.CodeInternal, err)
+ }
+ return templateToProto(tmpl)
+}
+
+func (s *Service) CreateFrameTemplate(ctx context.Context, req *connect.Request[framesv1.CreateFrameTemplateRequest]) (*connect.Response[framesv1.CreateFrameTemplateResponse], error) {
+ caller, err := s.requireAdmin(ctx)
+ if err != nil {
+ return nil, err
+ }
+ in, err := validateTemplateInput(req.Msg.Title, req.Msg.Description, req.Msg.Prefill, req.Msg.FieldRules)
+ if err != nil {
+ return nil, err
+ }
+ now := time.Now().UTC()
+ row := &store.FrameTemplate{
+ ID: newID(), OrgID: caller.OrgID, Title: in.title, Description: in.description,
+ Prefill: in.prefill, FieldRules: in.rules, CreatedBy: caller.Subject,
+ CreatedAt: now, UpdatedAt: now,
+ }
+ if err := s.repo.CreateFrameTemplate(ctx, row); err != nil {
+ if errors.Is(err, store.ErrAlreadyExists) {
+ return nil, connect.NewError(connect.CodeAlreadyExists,
+ fmt.Errorf("a template titled %q already exists in this organization", in.title))
+ }
+ return nil, connect.NewError(connect.CodeInternal, err)
+ }
+ pb, err := s.respondWithRow(ctx, caller.OrgID, row.ID)
+ if err != nil {
+ return nil, err
+ }
+ return connect.NewResponse(&framesv1.CreateFrameTemplateResponse{Template: pb}), nil
+}
+
+func (s *Service) UpdateFrameTemplate(ctx context.Context, req *connect.Request[framesv1.UpdateFrameTemplateRequest]) (*connect.Response[framesv1.UpdateFrameTemplateResponse], error) {
+ caller, err := s.requireAdmin(ctx)
+ if err != nil {
+ return nil, err
+ }
+ // Built-ins are compiled in, so there is no row to change. Refused for an
+ // admin too: this is a property of the artifact, not a permission.
+ if IsBuiltin(req.Msg.Id) {
+ return nil, connect.NewError(connect.CodeInvalidArgument,
+ errors.New("built-in templates cannot be edited; create an organization template instead"))
+ }
+ in, err := validateTemplateInput(req.Msg.Title, req.Msg.Description, req.Msg.Prefill, req.Msg.FieldRules)
+ if err != nil {
+ return nil, err
+ }
+ // The store matches on id AND org, so a row in another org is simply not
+ // found. There is no read-then-write window to lose a race in.
+ err = s.repo.UpdateFrameTemplate(ctx, &store.FrameTemplate{
+ ID: req.Msg.Id, OrgID: caller.OrgID, Title: in.title, Description: in.description,
+ Prefill: in.prefill, FieldRules: in.rules, UpdatedAt: time.Now().UTC(),
+ })
+ switch {
+ case errors.Is(err, store.ErrNotFound):
+ return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("no template %q", req.Msg.Id))
+ case errors.Is(err, store.ErrAlreadyExists):
+ return nil, connect.NewError(connect.CodeAlreadyExists,
+ fmt.Errorf("a template titled %q already exists in this organization", in.title))
+ case err != nil:
+ return nil, connect.NewError(connect.CodeInternal, err)
+ }
+ pb, err := s.respondWithRow(ctx, caller.OrgID, req.Msg.Id)
+ if err != nil {
+ return nil, err
+ }
+ return connect.NewResponse(&framesv1.UpdateFrameTemplateResponse{Template: pb}), nil
+}
+
+func (s *Service) DeleteFrameTemplate(ctx context.Context, req *connect.Request[framesv1.DeleteFrameTemplateRequest]) (*connect.Response[framesv1.DeleteFrameTemplateResponse], error) {
+ caller, err := s.requireAdmin(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if IsBuiltin(req.Msg.Id) {
+ return nil, connect.NewError(connect.CodeInvalidArgument,
+ errors.New("built-in templates cannot be deleted"))
+ }
+ // No dependency check, unlike DeleteFrame: a template is copied once at
+ // creation and nothing references it afterwards, so deleting one cannot
+ // affect any Frame made from it.
+ err = s.repo.DeleteFrameTemplate(ctx, caller.OrgID, req.Msg.Id)
+ if errors.Is(err, store.ErrNotFound) {
+ return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("no template %q", req.Msg.Id))
+ }
+ if err != nil {
+ return nil, connect.NewError(connect.CodeInternal, err)
+ }
+ return connect.NewResponse(&framesv1.DeleteFrameTemplateResponse{}), nil
}
diff --git a/backend/internal/frames/templates_service_test.go b/backend/internal/frames/templates_service_test.go
index 7ae1ca0..1d9f557 100644
--- a/backend/internal/frames/templates_service_test.go
+++ b/backend/internal/frames/templates_service_test.go
@@ -249,3 +249,231 @@ func TestParseFieldRulesRejectsUnknownLevel(t *testing.T) {
t.Errorf("err = %v, want it to name the slot", err)
}
}
+
+func TestCreateFrameTemplate(t *testing.T) {
+ f, ctx := newTemplateFixture(t, "admin")
+ prefill, err := MarshalPrefill(Prefill{Slots: Slots{Style: "Plain sentences."}})
+ if err != nil {
+ t.Fatalf("marshal prefill: %v", err)
+ }
+ resp, err := f.svc.CreateFrameTemplate(ctx, connect.NewRequest(&framesv1.CreateFrameTemplateRequest{
+ Title: "Our House Style", Description: "How we sound", Prefill: prefill,
+ FieldRules: map[string]*framesv1.FieldRule{
+ "style": {Level: framesv1.Requirement_REQUIREMENT_REQUIRED, Note: "Voice and tone."},
+ },
+ }))
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ got := resp.Msg.Template
+ if got.Id == "" || IsBuiltin(got.Id) {
+ t.Errorf("id = %q, want a non-builtin id", got.Id)
+ }
+ if got.Builtin {
+ t.Error("a created template is flagged builtin")
+ }
+ // It has to come back from a fresh read, not just from the create response.
+ fetched, err := f.svc.GetFrameTemplate(ctx, connect.NewRequest(&framesv1.GetFrameTemplateRequest{Id: got.Id}))
+ if err != nil {
+ t.Fatalf("get after create: %v", err)
+ }
+ if fetched.Msg.Template.FieldRules["style"].Level != framesv1.Requirement_REQUIREMENT_REQUIRED {
+ t.Errorf("stored level = %v", fetched.Msg.Template.FieldRules["style"].Level)
+ }
+ if fetched.Msg.Template.FieldRules["style"].Note != "Voice and tone." {
+ t.Errorf("stored note = %q", fetched.Msg.Template.FieldRules["style"].Note)
+ }
+}
+
+func TestCreateFrameTemplateValidation(t *testing.T) {
+ goodPrefill, err := MarshalPrefill(Prefill{Slots: Slots{Style: "x"}})
+ if err != nil {
+ t.Fatalf("marshal prefill: %v", err)
+ }
+ tests := []struct {
+ name string
+ req *framesv1.CreateFrameTemplateRequest
+ wantCode connect.Code
+ wantMsg string
+ }{
+ {
+ name: "valid",
+ req: &framesv1.CreateFrameTemplateRequest{Title: "T", Description: "D", Prefill: goodPrefill},
+ },
+ {
+ name: "title is required",
+ req: &framesv1.CreateFrameTemplateRequest{Description: "D", Prefill: goodPrefill},
+ wantCode: connect.CodeInvalidArgument, wantMsg: "title",
+ },
+ {
+ name: "description is required",
+ req: &framesv1.CreateFrameTemplateRequest{Title: "T", Prefill: goodPrefill},
+ wantCode: connect.CodeInvalidArgument, wantMsg: "description",
+ },
+ {
+ name: "prefill must not carry identity",
+ req: &framesv1.CreateFrameTemplateRequest{
+ Title: "T", Description: "D", Prefill: []byte("name: sneaky\nslots: {}\n"),
+ },
+ wantCode: connect.CodeInvalidArgument, wantMsg: "must not set name",
+ },
+ {
+ name: "an unknown slot in field_rules is refused",
+ req: &framesv1.CreateFrameTemplateRequest{
+ Title: "T", Description: "D", Prefill: goodPrefill,
+ FieldRules: map[string]*framesv1.FieldRule{"nosuchslot": {Level: framesv1.Requirement_REQUIREMENT_REQUIRED}},
+ },
+ wantCode: connect.CodeInvalidArgument, wantMsg: "unknown slot",
+ },
+ {
+ name: "empty prefill is accepted and means no seeded content",
+ req: &framesv1.CreateFrameTemplateRequest{Title: "T", Description: "D"},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ f, ctx := newTemplateFixture(t, "admin")
+ _, err := f.svc.CreateFrameTemplate(ctx, connect.NewRequest(tt.req))
+ if tt.wantCode == 0 {
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ return
+ }
+ if connect.CodeOf(err) != tt.wantCode {
+ t.Fatalf("code = %v (err %v), want %v", connect.CodeOf(err), err, tt.wantCode)
+ }
+ if tt.wantMsg != "" && !strings.Contains(err.Error(), tt.wantMsg) {
+ t.Errorf("err = %v, want it to mention %q", err, tt.wantMsg)
+ }
+ })
+ }
+}
+
+func TestCreateFrameTemplateDuplicateTitle(t *testing.T) {
+ f, ctx := newTemplateFixture(t, "admin")
+ req := &framesv1.CreateFrameTemplateRequest{Title: "Only One", Description: "D"}
+ if _, err := f.svc.CreateFrameTemplate(ctx, connect.NewRequest(req)); err != nil {
+ t.Fatalf("first create: %v", err)
+ }
+ _, err := f.svc.CreateFrameTemplate(ctx, connect.NewRequest(req))
+ if connect.CodeOf(err) != connect.CodeAlreadyExists {
+ t.Fatalf("code = %v (err %v), want AlreadyExists", connect.CodeOf(err), err)
+ }
+}
+
+func TestTemplateWritesAreAdminOnly(t *testing.T) {
+ roles := []struct {
+ role string
+ wantAllow bool
+ }{
+ {role: "admin", wantAllow: true},
+ {role: "publisher", wantAllow: false},
+ {role: "viewer", wantAllow: false},
+ }
+ for _, tt := range roles {
+ t.Run(tt.role, func(t *testing.T) {
+ f, ctx := newTemplateFixture(t, tt.role)
+ seedOrgTemplate(t, f.repo, "t1", "org-a", "Existing", nil)
+
+ _, createErr := f.svc.CreateFrameTemplate(ctx, connect.NewRequest(
+ &framesv1.CreateFrameTemplateRequest{Title: "New", Description: "D"}))
+ _, updateErr := f.svc.UpdateFrameTemplate(ctx, connect.NewRequest(
+ &framesv1.UpdateFrameTemplateRequest{Id: "t1", Title: "Renamed", Description: "D"}))
+ _, deleteErr := f.svc.DeleteFrameTemplate(ctx, connect.NewRequest(
+ &framesv1.DeleteFrameTemplateRequest{Id: "t1"}))
+
+ for name, err := range map[string]error{"create": createErr, "update": updateErr, "delete": deleteErr} {
+ if tt.wantAllow {
+ if err != nil {
+ t.Errorf("%s as %s = %v, want success", name, tt.role, err)
+ }
+ continue
+ }
+ if connect.CodeOf(err) != connect.CodePermissionDenied {
+ t.Errorf("%s as %s: code = %v (err %v), want PermissionDenied", name, tt.role, connect.CodeOf(err), err)
+ }
+ }
+ // The control: a non-admin can still read, or the assertions above
+ // would pass for a caller who simply has no access at all.
+ if _, err := f.svc.ListFrameTemplates(ctx, connect.NewRequest(&framesv1.ListFrameTemplatesRequest{})); err != nil {
+ t.Errorf("list as %s = %v, want success", tt.role, err)
+ }
+ })
+ }
+}
+
+func TestBuiltinTemplatesAreImmutable(t *testing.T) {
+ f, ctx := newTemplateFixture(t, "admin")
+ _, updateErr := f.svc.UpdateFrameTemplate(ctx, connect.NewRequest(&framesv1.UpdateFrameTemplateRequest{
+ Id: BlankTemplateID, Title: "Hijacked", Description: "D",
+ }))
+ if connect.CodeOf(updateErr) != connect.CodeInvalidArgument {
+ t.Errorf("update builtin: code = %v (err %v), want InvalidArgument", connect.CodeOf(updateErr), updateErr)
+ }
+ _, deleteErr := f.svc.DeleteFrameTemplate(ctx, connect.NewRequest(&framesv1.DeleteFrameTemplateRequest{
+ Id: BlankTemplateID,
+ }))
+ if connect.CodeOf(deleteErr) != connect.CodeInvalidArgument {
+ t.Errorf("delete builtin: code = %v (err %v), want InvalidArgument", connect.CodeOf(deleteErr), deleteErr)
+ }
+ // The control: the same admin can mutate one of their own.
+ seedOrgTemplate(t, f.repo, "t1", "org-a", "Mine", nil)
+ if _, err := f.svc.DeleteFrameTemplate(ctx, connect.NewRequest(&framesv1.DeleteFrameTemplateRequest{Id: "t1"})); err != nil {
+ t.Errorf("delete own template = %v, want success", err)
+ }
+ // And the built-in is still there afterwards.
+ if _, ok := BuiltinTemplate(BlankTemplateID); !ok {
+ t.Error("the built-in vanished")
+ }
+}
+
+func TestUpdateAndDeleteAreOrgScoped(t *testing.T) {
+ f, ctx := newTemplateFixture(t, "admin")
+ seedOrgTemplate(t, f.repo, "theirs", "org-b", "Theirs", nil)
+ _, updateErr := f.svc.UpdateFrameTemplate(ctx, connect.NewRequest(&framesv1.UpdateFrameTemplateRequest{
+ Id: "theirs", Title: "Stolen", Description: "D",
+ }))
+ if connect.CodeOf(updateErr) != connect.CodeNotFound {
+ t.Errorf("update across orgs: code = %v (err %v), want NotFound", connect.CodeOf(updateErr), updateErr)
+ }
+ _, deleteErr := f.svc.DeleteFrameTemplate(ctx, connect.NewRequest(&framesv1.DeleteFrameTemplateRequest{Id: "theirs"}))
+ if connect.CodeOf(deleteErr) != connect.CodeNotFound {
+ t.Errorf("delete across orgs: code = %v (err %v), want NotFound", connect.CodeOf(deleteErr), deleteErr)
+ }
+ // The other org's row survived.
+ if _, err := f.repo.GetFrameTemplate(context.Background(), "org-b", "theirs"); err != nil {
+ t.Errorf("the other org's template was affected: %v", err)
+ }
+}
+
+func TestOrgTemplateIsVisibleToAnotherMember(t *testing.T) {
+ // Journey 5's "persist and are usable by org members" half. A second member
+ // of the same org, not the author, must see it.
+ f, ctx := newTemplateFixture(t, "admin")
+ created, err := f.svc.CreateFrameTemplate(ctx, connect.NewRequest(&framesv1.CreateFrameTemplateRequest{
+ Title: "Shared Standard", Description: "D",
+ }))
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ if err := f.repo.UpsertMembership(context.Background(), &framesv1.Membership{
+ OrgId: "org-a", UserSub: "user-second", Role: "viewer", AddedAt: timestamppb.Now(),
+ }); err != nil {
+ t.Fatalf("second membership: %v", err)
+ }
+ otherCtx := auth.WithClaims(context.Background(), &auth.Claims{Subject: "user-second", Email: "s@example.com"})
+ resp, err := f.svc.ListFrameTemplates(otherCtx, connect.NewRequest(&framesv1.ListFrameTemplatesRequest{}))
+ if err != nil {
+ t.Fatalf("list as the second member: %v", err)
+ }
+ found := false
+ for _, s := range resp.Msg.Templates {
+ if s.Id == created.Msg.Template.Id {
+ found = true
+ }
+ }
+ if !found {
+ t.Error("a second member of the org cannot see the template")
+ }
+}
diff --git a/cli/internal/testutil/stub_server.go b/cli/internal/testutil/stub_server.go
index 4f7b6a0..625fc9e 100644
--- a/cli/internal/testutil/stub_server.go
+++ b/cli/internal/testutil/stub_server.go
@@ -15,12 +15,17 @@ import (
// StubService implements framesv1connect.FrameServiceHandler via optional func
// fields. Unset methods return CodeUnimplemented.
type StubService struct {
- PublishFn func(context.Context, *connect.Request[framesv1.PublishFrameRequest]) (*connect.Response[framesv1.PublishFrameResponse], error)
- ListFn func(context.Context, *connect.Request[framesv1.ListFramesRequest]) (*connect.Response[framesv1.ListFramesResponse], error)
- GetFn func(context.Context, *connect.Request[framesv1.GetFrameRequest]) (*connect.Response[framesv1.GetFrameResponse], error)
- ResolveFn func(context.Context, *connect.Request[framesv1.ResolveFrameRequest]) (*connect.Response[framesv1.ResolveFrameResponse], error)
- MeFn func(context.Context, *connect.Request[framesv1.GetMeRequest]) (*connect.Response[framesv1.GetMeResponse], error)
- ListVersionsFn func(context.Context, *connect.Request[framesv1.ListFrameVersionsRequest]) (*connect.Response[framesv1.ListFrameVersionsResponse], error)
+ PublishFn func(context.Context, *connect.Request[framesv1.PublishFrameRequest]) (*connect.Response[framesv1.PublishFrameResponse], error)
+ ListFn func(context.Context, *connect.Request[framesv1.ListFramesRequest]) (*connect.Response[framesv1.ListFramesResponse], error)
+ GetFn func(context.Context, *connect.Request[framesv1.GetFrameRequest]) (*connect.Response[framesv1.GetFrameResponse], error)
+ ResolveFn func(context.Context, *connect.Request[framesv1.ResolveFrameRequest]) (*connect.Response[framesv1.ResolveFrameResponse], error)
+ MeFn func(context.Context, *connect.Request[framesv1.GetMeRequest]) (*connect.Response[framesv1.GetMeResponse], error)
+ ListVersionsFn func(context.Context, *connect.Request[framesv1.ListFrameVersionsRequest]) (*connect.Response[framesv1.ListFrameVersionsResponse], error)
+ ListTemplatesFn func(context.Context, *connect.Request[framesv1.ListFrameTemplatesRequest]) (*connect.Response[framesv1.ListFrameTemplatesResponse], error)
+ GetTemplateFn func(context.Context, *connect.Request[framesv1.GetFrameTemplateRequest]) (*connect.Response[framesv1.GetFrameTemplateResponse], error)
+ CreateTemplateFn func(context.Context, *connect.Request[framesv1.CreateFrameTemplateRequest]) (*connect.Response[framesv1.CreateFrameTemplateResponse], error)
+ UpdateTemplateFn func(context.Context, *connect.Request[framesv1.UpdateFrameTemplateRequest]) (*connect.Response[framesv1.UpdateFrameTemplateResponse], error)
+ DeleteTemplateFn func(context.Context, *connect.Request[framesv1.DeleteFrameTemplateRequest]) (*connect.Response[framesv1.DeleteFrameTemplateResponse], error)
}
var _ framesv1connect.FrameServiceHandler = (*StubService)(nil)
@@ -94,6 +99,41 @@ func (s *StubService) RemoveOrgMember(_ context.Context, r *connect.Request[fram
return nil, unimpl()
}
+func (s *StubService) ListFrameTemplates(ctx context.Context, r *connect.Request[framesv1.ListFrameTemplatesRequest]) (*connect.Response[framesv1.ListFrameTemplatesResponse], error) {
+ if s.ListTemplatesFn != nil {
+ return s.ListTemplatesFn(ctx, r)
+ }
+ return nil, unimpl()
+}
+
+func (s *StubService) GetFrameTemplate(ctx context.Context, r *connect.Request[framesv1.GetFrameTemplateRequest]) (*connect.Response[framesv1.GetFrameTemplateResponse], error) {
+ if s.GetTemplateFn != nil {
+ return s.GetTemplateFn(ctx, r)
+ }
+ return nil, unimpl()
+}
+
+func (s *StubService) CreateFrameTemplate(ctx context.Context, r *connect.Request[framesv1.CreateFrameTemplateRequest]) (*connect.Response[framesv1.CreateFrameTemplateResponse], error) {
+ if s.CreateTemplateFn != nil {
+ return s.CreateTemplateFn(ctx, r)
+ }
+ return nil, unimpl()
+}
+
+func (s *StubService) UpdateFrameTemplate(ctx context.Context, r *connect.Request[framesv1.UpdateFrameTemplateRequest]) (*connect.Response[framesv1.UpdateFrameTemplateResponse], error) {
+ if s.UpdateTemplateFn != nil {
+ return s.UpdateTemplateFn(ctx, r)
+ }
+ return nil, unimpl()
+}
+
+func (s *StubService) DeleteFrameTemplate(ctx context.Context, r *connect.Request[framesv1.DeleteFrameTemplateRequest]) (*connect.Response[framesv1.DeleteFrameTemplateResponse], error) {
+ if s.DeleteTemplateFn != nil {
+ return s.DeleteTemplateFn(ctx, r)
+ }
+ return nil, unimpl()
+}
+
// NewStubServer mounts h on an httptest.Server and returns the base URL.
func NewStubServer(t *testing.T, h framesv1connect.FrameServiceHandler) string {
t.Helper()
From 0aab1cf784c9fbead93fd3f205825062f3361af0 Mon Sep 17 00:00:00 2001
From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com>
Date: Mon, 7 Sep 2026 12:58:22 +0200
Subject: [PATCH 10/43] fix(frames): drop stale Task 8 reference from
requirementToProto comment
---
backend/internal/frames/templates_service.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/backend/internal/frames/templates_service.go b/backend/internal/frames/templates_service.go
index a306512..9add20b 100644
--- a/backend/internal/frames/templates_service.go
+++ b/backend/internal/frames/templates_service.go
@@ -26,8 +26,8 @@ import (
// requirementToProto converts the level for the wire. The proto enum has an
// explicit UNSPECIFIED that maps to optional, so an older client that leaves
// the field unset gets the permissive reading rather than an accidental
-// requirement. The reverse direction belongs to the write handlers in Task 8,
-// which is the only place anything decodes a caller-supplied level.
+// requirement. requirementFromProto below is the reverse direction, used by the
+// write handlers: they are the only place a caller-supplied level is decoded.
func requirementToProto(r Requirement) framesv1.Requirement {
switch r {
case RequirementRecommended:
From 6de11f89740172437c3c075064c7795660285d09 Mon Sep 17 00:00:00 2001
From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com>
Date: Mon, 7 Sep 2026 13:07:22 +0200
Subject: [PATCH 11/43] feat(frames): check a template's required slots when
creating a Frame from it
Publishing with template_id now asserts create intent and refuses the
publish when a required slot is empty, merging those violations with any
schema errors into one response. An unusable template_id (unknown or another
org's) is reported as an invalid publish request, not a missing template, so
existence does not leak. PublishDoc and PublishDocFrom keep their existing
signatures as thin wrappers over the new PublishDocRequest/PublishRequest
entry point, so no existing caller changes.
---
.../internal/frames/publish_template_test.go | 208 ++++++++++++++++++
backend/internal/frames/service.go | 94 ++++++--
2 files changed, 286 insertions(+), 16 deletions(-)
create mode 100644 backend/internal/frames/publish_template_test.go
diff --git a/backend/internal/frames/publish_template_test.go b/backend/internal/frames/publish_template_test.go
new file mode 100644
index 0000000..a968e16
--- /dev/null
+++ b/backend/internal/frames/publish_template_test.go
@@ -0,0 +1,208 @@
+package frames
+
+import (
+ "errors"
+ "testing"
+
+ "connectrpc.com/connect"
+
+ framesv1 "github.com/nebari-dev/nebari-frames/gen/go/frames/v1"
+)
+
+// These tests exercise PublishDocRequest's template check. They live in
+// package frames, alongside templates_service_test.go, rather than in the
+// external frames_test package that service_test.go uses: newTemplateFixture
+// and seedOrgTemplate are unexported, so an external test package cannot reach
+// them, and duplicating those fixtures here would defeat the point of reusing
+// them.
+
+func TestPublishChecksTemplateRequirements(t *testing.T) {
+ docWith := func(slots Slots) *Doc {
+ return &Doc{Name: "test-frame", Description: "A test frame", Version: "1.0.0", Slots: slots}
+ }
+ tests := []struct {
+ name string
+ templateID string
+ slots Slots
+ wantPaths []string
+ wantCode connect.Code
+ }{
+ {
+ name: "no template means no template checks",
+ templateID: "",
+ slots: Slots{},
+ },
+ {
+ name: "required slot empty is refused, naming the slot",
+ templateID: "builtin:domain-vocabulary",
+ slots: Slots{},
+ wantPaths: []string{"slots.terminology"},
+ wantCode: connect.CodeInvalidArgument,
+ },
+ {
+ name: "required slot filled publishes",
+ templateID: "builtin:domain-vocabulary",
+ slots: Slots{Terminology: []Term{{Term: "Frame", Definition: "A scoped context artifact."}}},
+ },
+ {
+ // domain-vocabulary marks `rules` recommended. Leaving it empty while
+ // the required slot is filled must still publish: recommended is
+ // guidance, and this is the case that proves it is not quietly
+ // enforced. Without the explicit nil this is the previous case again.
+ name: "a recommended slot left empty still publishes",
+ templateID: "builtin:domain-vocabulary",
+ slots: Slots{Terminology: []Term{{Term: "Frame", Definition: "A scoped context artifact."}}, Rules: nil},
+ },
+ {
+ name: "an unknown template id is refused",
+ templateID: "builtin:nope",
+ slots: Slots{},
+ wantCode: connect.CodeInvalidArgument,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ f, ctx := newTemplateFixture(t, "admin")
+ _, _, err := f.svc.PublishDocRequest(ctx, docWith(tt.slots), PublishRequest{
+ Intent: PublishCreate, TemplateID: tt.templateID,
+ })
+ if tt.wantCode == 0 {
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ return
+ }
+ if connect.CodeOf(err) != tt.wantCode {
+ t.Fatalf("code = %v (err %v), want %v", connect.CodeOf(err), err, tt.wantCode)
+ }
+ if len(tt.wantPaths) == 0 {
+ return
+ }
+ // The violation must arrive as a FieldViolations detail keyed by the
+ // slot path, or the web form cannot put the message on the section.
+ var ce *connect.Error
+ if !errors.As(err, &ce) {
+ t.Fatalf("error is not a connect error: %v", err)
+ }
+ got := map[string]bool{}
+ for _, d := range ce.Details() {
+ msg, decodeErr := d.Value()
+ if decodeErr != nil {
+ continue
+ }
+ if fv, ok := msg.(*framesv1.FieldViolations); ok {
+ for _, v := range fv.Violations {
+ got[v.Field] = true
+ }
+ }
+ }
+ for _, want := range tt.wantPaths {
+ if !got[want] {
+ t.Errorf("no field violation for %q; got %v", want, got)
+ }
+ }
+ })
+ }
+}
+
+func TestPublishMergesSchemaAndTemplateViolations(t *testing.T) {
+ // An author fixing schema errors and only then discovering the template's
+ // requirements is a worse experience than seeing both at once.
+ f, ctx := newTemplateFixture(t, "admin")
+ _, _, err := f.svc.PublishDocRequest(ctx, &Doc{
+ Name: "BAD NAME", Description: "", Version: "1.0.0",
+ }, PublishRequest{Intent: PublishCreate, TemplateID: "builtin:domain-vocabulary"})
+ if err == nil {
+ t.Fatal("publish succeeded on an invalid document")
+ }
+ var ce *connect.Error
+ if !errors.As(err, &ce) {
+ t.Fatalf("not a connect error: %v", err)
+ }
+ got := map[string]bool{}
+ for _, d := range ce.Details() {
+ msg, decodeErr := d.Value()
+ if decodeErr != nil {
+ continue
+ }
+ if fv, ok := msg.(*framesv1.FieldViolations); ok {
+ for _, v := range fv.Violations {
+ got[v.Field] = true
+ }
+ }
+ }
+ for _, want := range []string{"name", "description", "slots.terminology"} {
+ if !got[want] {
+ t.Errorf("missing violation for %q; got %v", want, got)
+ }
+ }
+}
+
+func TestPublishWithTemplateAgainstAnExistingFrameIsRefused(t *testing.T) {
+ f, ctx := newTemplateFixture(t, "admin")
+ doc := &Doc{
+ Name: "test-frame", Description: "A test frame", Version: "1.0.0",
+ Slots: Slots{Terminology: []Term{{Term: "Frame", Definition: "A scoped context artifact."}}},
+ }
+ if _, _, err := f.svc.PublishDocRequest(ctx, doc, PublishRequest{
+ Intent: PublishCreate, TemplateID: "builtin:domain-vocabulary",
+ }); err != nil {
+ t.Fatalf("first publish: %v", err)
+ }
+ doc.Version = "1.0.1"
+ _, _, err := f.svc.PublishDocRequest(ctx, doc, PublishRequest{
+ Intent: PublishCreate, TemplateID: "builtin:domain-vocabulary",
+ })
+ if connect.CodeOf(err) != connect.CodeAlreadyExists {
+ t.Fatalf("code = %v (err %v), want AlreadyExists", connect.CodeOf(err), err)
+ }
+}
+
+func TestAFrameFromATemplateIsOrdinaryAfterwards(t *testing.T) {
+ // Journey 9. Requirements are checked at creation only: nothing is recorded
+ // on the Frame, so a later version may empty the section, and deleting the
+ // template cannot affect it.
+ f, ctx := newTemplateFixture(t, "admin")
+ seedOrgTemplate(t, f.repo, "t1", "org-a", "Vocabulary", map[string]FieldRule{
+ "terminology": {Level: RequirementRequired, Note: "Define your terms."},
+ })
+ doc := &Doc{
+ Name: "test-frame", Description: "A test frame", Version: "1.0.0",
+ Slots: Slots{Terminology: []Term{{Term: "Frame", Definition: "A scoped context artifact."}}},
+ }
+ if _, _, err := f.svc.PublishDocRequest(ctx, doc, PublishRequest{
+ Intent: PublishCreate, TemplateID: "t1",
+ }); err != nil {
+ t.Fatalf("create from template: %v", err)
+ }
+
+ // Delete the template out from under the Frame.
+ if _, err := f.svc.DeleteFrameTemplate(ctx, connect.NewRequest(&framesv1.DeleteFrameTemplateRequest{Id: "t1"})); err != nil {
+ t.Fatalf("delete template: %v", err)
+ }
+
+ // A later version may empty the once-required section.
+ if _, _, err := f.svc.PublishDocRequest(ctx, &Doc{
+ Name: "test-frame", Description: "A test frame", Version: "1.0.1",
+ }, PublishRequest{Intent: PublishUpdate}); err != nil {
+ t.Fatalf("publishing an update after the template was deleted: %v", err)
+ }
+}
+
+func TestPublishRejectsAnotherOrgsTemplateID(t *testing.T) {
+ f, ctx := newTemplateFixture(t, "admin")
+ seedOrgTemplate(t, f.repo, "theirs", "org-b", "Theirs", nil)
+ _, _, err := f.svc.PublishDocRequest(ctx, &Doc{
+ Name: "test-frame", Description: "A test frame", Version: "1.0.0",
+ }, PublishRequest{Intent: PublishCreate, TemplateID: "theirs"})
+ if connect.CodeOf(err) != connect.CodeInvalidArgument {
+ t.Fatalf("code = %v (err %v), want InvalidArgument", connect.CodeOf(err), err)
+ }
+ // The control: the caller's own template on the same call succeeds.
+ seedOrgTemplate(t, f.repo, "mine", "org-a", "Mine", nil)
+ if _, _, err := f.svc.PublishDocRequest(ctx, &Doc{
+ Name: "test-frame", Description: "A test frame", Version: "1.0.0",
+ }, PublishRequest{Intent: PublishCreate, TemplateID: "mine"}); err != nil {
+ t.Fatalf("own template = %v, want success", err)
+ }
+}
diff --git a/backend/internal/frames/service.go b/backend/internal/frames/service.go
index 8df6a8b..72d5aaf 100644
--- a/backend/internal/frames/service.go
+++ b/backend/internal/frames/service.go
@@ -116,10 +116,20 @@ func (s *Service) PublishFrame(ctx context.Context, req *connect.Request[framesv
if err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, err)
}
+ intent := PublishUpsert
+ if req.Msg.TemplateId != "" {
+ // Naming a template asserts this is a new Frame. A template is copied
+ // once at creation, so applying one to a Frame that already exists is a
+ // mistake rather than an update, and the existing create-intent branch
+ // below reports it as AlreadyExists with a message that says so.
+ intent = PublishCreate
+ }
// The submitted bytes are stored verbatim rather than re-marshalled from
// doc: an author's comments and formatting survive, and the digest stays
// stable for a document that did not change.
- frame, version, err := s.publish(ctx, caller, doc, req.Msg.Content, req.Msg.Changelog, PublishUpsert, "")
+ frame, version, err := s.publish(ctx, caller, doc, req.Msg.Content, PublishRequest{
+ Changelog: req.Msg.Changelog, Intent: intent, TemplateID: req.Msg.TemplateId,
+ })
if err != nil {
return nil, err
}
@@ -139,6 +149,24 @@ func (s *Service) authorizePublish(ctx context.Context) (rbac.Caller, error) {
return caller, nil
}
+// PublishRequest carries what a publish needs beyond the document itself. A
+// struct rather than more positional parameters: publish already takes seven,
+// and the same reasoning behind the Option type on Service applies here - adding
+// an input should not churn every call site.
+type PublishRequest struct {
+ Changelog string
+ Intent PublishIntent
+ // BaseVersion is the version the caller read before composing doc. Empty
+ // means the caller did not check, which is the unguarded behaviour the
+ // Connect RPC has always had.
+ BaseVersion string
+ // TemplateID names the template this Frame is being created from. It is used
+ // to check that template's required slots and is then discarded: nothing is
+ // recorded on the Frame, and a later publish is never re-checked. Empty means
+ // no template and no checks.
+ TemplateID string
+}
+
// PublishDoc validates and publishes doc as a new version, enforcing RBAC:
// creating a frame needs the publisher or admin role, and writing to an
// existing frame needs edit permission on it. It is the single write path
@@ -148,38 +176,72 @@ func (s *Service) authorizePublish(ctx context.Context) (rbac.Caller, error) {
// Errors are connect errors so both front doors can map them without
// translation: PermissionDenied, InvalidArgument (with field violations),
// AlreadyExists, NotFound, Internal.
+//
+// PublishDoc itself publishes with no concurrency check and no template.
func (s *Service) PublishDoc(ctx context.Context, doc *Doc, changelog string, intent PublishIntent) (*framesv1.Frame, *framesv1.FrameVersion, error) {
- return s.PublishDocFrom(ctx, doc, changelog, intent, "")
+ return s.PublishDocRequest(ctx, doc, PublishRequest{Changelog: changelog, Intent: intent})
}
-// PublishDocFrom is PublishDoc with a concurrency check. baseVersion is the
-// version the caller read before composing doc; the publish is rejected with
-// CodeFailedPrecondition when the frame has moved on since. An empty
-// baseVersion means the caller did not check, which is the unguarded behaviour
-// the Connect RPC has always had.
+// PublishDocFrom is PublishDoc with a concurrency check. See PublishRequest.
//
// A read-modify-write without this check silently loses one of two concurrent
// updates: both merge onto the same base, both pick different version strings
// so nothing collides, and both report success.
func (s *Service) PublishDocFrom(ctx context.Context, doc *Doc, changelog string, intent PublishIntent, baseVersion string) (*framesv1.Frame, *framesv1.FrameVersion, error) {
+ return s.PublishDocRequest(ctx, doc, PublishRequest{
+ Changelog: changelog, Intent: intent, BaseVersion: baseVersion,
+ })
+}
+
+// PublishDocRequest is the full-fidelity entry point. It marshals doc rather
+// than storing caller bytes, so comments and formatting survive a CLI or web
+// publish only on the Connect path, which passes its own content.
+func (s *Service) PublishDocRequest(ctx context.Context, doc *Doc, req PublishRequest) (*framesv1.Frame, *framesv1.FrameVersion, error) {
caller, err := s.authorizePublish(ctx)
if err != nil {
return nil, nil, err
}
content, err := Marshal(doc)
if err != nil {
- return nil, nil, connect.NewError(connect.CodeInternal, err)
+ return nil, nil, connect.NewError(connect.CodeInvalidArgument, err)
}
- return s.publish(ctx, caller, doc, content, changelog, intent, baseVersion)
+ return s.publish(ctx, caller, doc, content, req)
}
// publish is the shared implementation, called only with a caller that
// authorizePublish has already cleared. content is the canonical stored form of
// doc; callers holding the author's original bytes pass those so they are not
// normalized away.
-func (s *Service) publish(ctx context.Context, caller rbac.Caller, doc *Doc, content []byte, changelog string, intent PublishIntent, baseVersion string) (*framesv1.Frame, *framesv1.FrameVersion, error) {
+func (s *Service) publish(ctx context.Context, caller rbac.Caller, doc *Doc, content []byte, req PublishRequest) (*framesv1.Frame, *framesv1.FrameVersion, error) {
+ // Schema violations are collected rather than returned straight away, so
+ // they can be reported together with the template's. An author who fixes
+ // three schema errors and only then discovers two more required sections has
+ // been made to do the work twice.
+ var fieldErrs []FieldError
if verr := Validate(doc); verr != nil {
- return nil, nil, violationErr(verr)
+ var ve *ValidationError
+ if !errors.As(verr, &ve) {
+ return nil, nil, violationErr(verr)
+ }
+ fieldErrs = ve.Errors
+ }
+ if req.TemplateID != "" {
+ tmpl, err := s.lookupTemplate(ctx, caller, req.TemplateID)
+ if err != nil {
+ // A template the caller cannot use makes the publish request wrong,
+ // not the template missing: what is being rejected is the Frame they
+ // asked to create. Another org's id lands here too, and reports the
+ // same thing an unknown id does so existence does not leak.
+ if code := connect.CodeOf(err); code == connect.CodeNotFound || code == connect.CodeInvalidArgument {
+ return nil, nil, connect.NewError(connect.CodeInvalidArgument,
+ fmt.Errorf("unknown template_id %q", req.TemplateID))
+ }
+ return nil, nil, err
+ }
+ fieldErrs = append(fieldErrs, tmpl.Check(doc)...)
+ }
+ if len(fieldErrs) > 0 {
+ return nil, nil, violationErr(&ValidationError{Errors: fieldErrs})
}
// Enforced here rather than at either entry point so the Connect API and the
// MCP tools share one limit. It matters more now that an LLM can author a
@@ -201,9 +263,9 @@ func (s *Service) publish(ctx context.Context, caller rbac.Caller, doc *Doc, con
return nil, nil, connect.NewError(connect.CodeInternal, err)
}
switch {
- case isNew && intent == PublishUpdate:
+ case isNew && req.Intent == PublishUpdate:
return nil, nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("no frame named %q to update", doc.Name))
- case !isNew && intent == PublishCreate:
+ case !isNew && req.Intent == PublishCreate:
return nil, nil, connect.NewError(connect.CodeAlreadyExists, fmt.Errorf("a frame named %q already exists; update it instead", doc.Name))
}
@@ -223,10 +285,10 @@ func (s *Service) publish(ctx context.Context, caller rbac.Caller, doc *Doc, con
if !allowed {
return nil, nil, connect.NewError(connect.CodePermissionDenied, errors.New("edit permission required"))
}
- if baseVersion != "" && existing.LatestVersion != baseVersion {
+ if req.BaseVersion != "" && existing.LatestVersion != req.BaseVersion {
return nil, nil, connect.NewError(connect.CodeFailedPrecondition, fmt.Errorf(
"frame %q has moved on: you based this change on %s but the latest is %s; re-read it and apply your change again",
- doc.Name, baseVersion, existing.LatestVersion))
+ doc.Name, req.BaseVersion, existing.LatestVersion))
}
// latest_version must only move forward. Otherwise publishing an older
// version silently unpublishes newer content: every default read, and
@@ -261,7 +323,7 @@ func (s *Service) publish(ctx context.Context, caller rbac.Caller, doc *Doc, con
digest := sha256.Sum256(content)
version := &framesv1.FrameVersion{
- Version: doc.Version, Changelog: changelog, Digest: hex.EncodeToString(digest[:]),
+ Version: doc.Version, Changelog: req.Changelog, Digest: hex.EncodeToString(digest[:]),
SizeBytes: int64(len(content)), PublishedBy: caller.Subject, PublishedAt: now,
Content: content,
}
From 85bda5462532944d74cb113c56067cfb2d5b4495 Mon Sep 17 00:00:00 2001
From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com>
Date: Mon, 7 Sep 2026 13:12:29 +0200
Subject: [PATCH 12/43] fix(frames): report a PublishDocRequest marshal failure
as Internal, not InvalidArgument
Marshal here serializes a document the server already built and validated,
so a failure is a server-side invariant break, not bad caller input. Matches
ConvertFrame's handling of the same call elsewhere in this file.
---
backend/internal/frames/service.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/backend/internal/frames/service.go b/backend/internal/frames/service.go
index 72d5aaf..47047c1 100644
--- a/backend/internal/frames/service.go
+++ b/backend/internal/frames/service.go
@@ -203,7 +203,7 @@ func (s *Service) PublishDocRequest(ctx context.Context, doc *Doc, req PublishRe
}
content, err := Marshal(doc)
if err != nil {
- return nil, nil, connect.NewError(connect.CodeInvalidArgument, err)
+ return nil, nil, connect.NewError(connect.CodeInternal, err)
}
return s.publish(ctx, caller, doc, content, req)
}
From 54eca6b93448e2d4782737b2c2394ddcb1453347 Mon Sep 17 00:00:00 2001
From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com>
Date: Mon, 7 Sep 2026 13:27:51 +0200
Subject: [PATCH 13/43] feat(web): offer a template picker at the start of the
create flow
---
.../components/frame/TemplatePicker.test.tsx | 47 +++++++++++
web/src/components/frame/TemplatePicker.tsx | 59 +++++++++++++
web/src/lib/templates.test.ts | 82 +++++++++++++++++++
web/src/lib/templates.ts | 61 ++++++++++++++
.../pages/FrameAuthoringPage.create.test.tsx | 9 +-
.../FrameAuthoringPage.markdown.test.tsx | 9 +-
.../FrameAuthoringPage.template.test.tsx | 75 +++++++++++++++++
web/src/pages/FrameAuthoringPage.tsx | 22 ++++-
8 files changed, 361 insertions(+), 3 deletions(-)
create mode 100644 web/src/components/frame/TemplatePicker.test.tsx
create mode 100644 web/src/components/frame/TemplatePicker.tsx
create mode 100644 web/src/lib/templates.test.ts
create mode 100644 web/src/lib/templates.ts
create mode 100644 web/src/pages/FrameAuthoringPage.template.test.tsx
diff --git a/web/src/components/frame/TemplatePicker.test.tsx b/web/src/components/frame/TemplatePicker.test.tsx
new file mode 100644
index 0000000..b6f6ea2
--- /dev/null
+++ b/web/src/components/frame/TemplatePicker.test.tsx
@@ -0,0 +1,47 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { expect, it, vi } from "vitest";
+import { create } from "@bufbuild/protobuf";
+import { FrameTemplateSummarySchema } from "@gen/frames/v1/frame_pb";
+import { TemplatePicker } from "./TemplatePicker";
+
+// Built with create() rather than object literals: the generated summary is a
+// protobuf-es Message and carries a $typeName a literal cannot satisfy. This is
+// the same pattern DeleteFrameDialog.test.tsx and publish-errors.test.ts use.
+const templates = [
+ create(FrameTemplateSummarySchema, {
+ id: "builtin:blank", title: "Blank", description: "Start from an empty document.", builtin: true,
+ }),
+ create(FrameTemplateSummarySchema, {
+ id: "builtin:brand-voice", title: "Brand Voice and Style", description: "How your org sounds.", builtin: true,
+ }),
+ create(FrameTemplateSummarySchema, {
+ id: "01JORGTEMPLATE0000000000AB", title: "Our House Style", description: "Ours.", builtin: false,
+ }),
+];
+
+it("groups the org's templates separately from the built-ins", () => {
+ render( );
+ expect(screen.getByRole("heading", { name: /your organization/i })).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: /built-in/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /our house style/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /blank/i })).toBeInTheDocument();
+});
+
+it("omits the organization group when the org has no templates", () => {
+ render( t.builtin)} onPick={vi.fn()} />);
+ expect(screen.queryByRole("heading", { name: /your organization/i })).not.toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: /built-in/i })).toBeInTheDocument();
+});
+
+it("shows each template's description", () => {
+ render( );
+ expect(screen.getByText("How your org sounds.")).toBeInTheDocument();
+});
+
+it("reports the chosen template's id", async () => {
+ const onPick = vi.fn();
+ render( );
+ await userEvent.click(screen.getByRole("button", { name: /brand voice and style/i }));
+ expect(onPick).toHaveBeenCalledWith("builtin:brand-voice");
+});
diff --git a/web/src/components/frame/TemplatePicker.tsx b/web/src/components/frame/TemplatePicker.tsx
new file mode 100644
index 0000000..b2652aa
--- /dev/null
+++ b/web/src/components/frame/TemplatePicker.tsx
@@ -0,0 +1,59 @@
+import type { FrameTemplateSummary } from "@gen/frames/v1/frame_pb";
+import { Button } from "@/components/ui/button";
+
+// Org templates and built-ins are shown in separate groups rather than merged
+// or shadowed. An org template sharing a built-in's title sits beside it: a
+// precedence rule would be a permanent source of "why am I seeing the wrong
+// one", and grouping answers the question visually instead.
+function Group({
+ heading,
+ templates,
+ onPick,
+}: {
+ heading: string;
+ templates: FrameTemplateSummary[];
+ onPick: (id: string) => void;
+}) {
+ if (templates.length === 0) return null;
+ return (
+
+ {heading}
+
+ {templates.map((t) => (
+ onPick(t.id)}
+ >
+ {t.title}
+ {t.description}
+
+ ))}
+
+
+ );
+}
+
+export function TemplatePicker({
+ templates,
+ onPick,
+}: {
+ templates: FrameTemplateSummary[];
+ onPick: (id: string) => void;
+}) {
+ return (
+
+
+
What kind of Frame is this?
+
+ A template gives you a starting shape. Everything it fills in stays fully editable, and a
+ Frame keeps no link to the template it started from.
+
+
+
!t.builtin)} onPick={onPick} />
+ t.builtin)} onPick={onPick} />
+
+ );
+}
diff --git a/web/src/lib/templates.test.ts b/web/src/lib/templates.test.ts
new file mode 100644
index 0000000..bfd2a1c
--- /dev/null
+++ b/web/src/lib/templates.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from "vitest";
+import { Requirement } from "@gen/frames/v1/frame_pb";
+import { seededSections, isRequiredSection, sectionHint, publishTemplateID, type TemplateRules } from "./templates";
+import { SLOT_SECTIONS } from "./slot-sections";
+
+const terminology = SLOT_SECTIONS.find((s) => s.key === "terminology")!;
+
+describe("seededSections", () => {
+ it("returns nothing for a template with no rules", () => {
+ expect(seededSections({})).toEqual([]);
+ });
+
+ it("includes required and recommended sections but not optional ones", () => {
+ const rules: TemplateRules = {
+ terminology: { level: Requirement.REQUIRED, note: "" },
+ rules: { level: Requirement.RECOMMENDED, note: "" },
+ style: { level: Requirement.OPTIONAL, note: "" },
+ };
+ expect(seededSections(rules).map((s) => s.key)).toEqual(["terminology", "rules"]);
+ });
+
+ it("returns sections in schema order, not rule-map order", () => {
+ const rules: TemplateRules = {
+ business_process: { level: Requirement.REQUIRED, note: "" },
+ terminology: { level: Requirement.REQUIRED, note: "" },
+ style: { level: Requirement.REQUIRED, note: "" },
+ };
+ expect(seededSections(rules).map((s) => s.key)).toEqual(["terminology", "style", "business_process"]);
+ });
+
+ it("ignores a rule naming a slot that does not exist", () => {
+ const rules: TemplateRules = { nosuchslot: { level: Requirement.REQUIRED, note: "" } };
+ expect(seededSections(rules)).toEqual([]);
+ });
+});
+
+describe("publishTemplateID", () => {
+ // Which id a publish carries is a pure rule, so it is tested as one rather
+ // than inferred from whether a form submission happened to fire.
+ it.each([
+ [{ mode: "create" as const, importing: false, templateID: "builtin:blank" }, "builtin:blank"],
+ // Edit never carries a template: requirements are checked on create only.
+ [{ mode: "edit" as const, importing: false, templateID: "builtin:blank" }, ""],
+ // The import path brought its own document; applying a standard the author
+ // never chose would be a surprise.
+ [{ mode: "create" as const, importing: true, templateID: "builtin:blank" }, ""],
+ [{ mode: "create" as const, importing: false, templateID: "" }, ""],
+ ])("%o -> %s", (input, want) => {
+ expect(publishTemplateID(input)).toBe(want);
+ });
+});
+
+describe("isRequiredSection", () => {
+ it.each([
+ [Requirement.REQUIRED, true],
+ [Requirement.RECOMMENDED, false],
+ [Requirement.OPTIONAL, false],
+ [Requirement.UNSPECIFIED, false],
+ ])("level %s -> %s", (level, want) => {
+ expect(isRequiredSection({ terminology: { level, note: "" } }, "terminology")).toBe(want);
+ });
+
+ it("is false for a slot with no rule", () => {
+ expect(isRequiredSection({}, "terminology")).toBe(false);
+ });
+});
+
+describe("sectionHint", () => {
+ it("prefers the template's note over the generic hint", () => {
+ const rules: TemplateRules = { terminology: { level: Requirement.REQUIRED, note: "One entry per term of art." } };
+ expect(sectionHint(rules, terminology)).toBe("One entry per term of art.");
+ });
+
+ it("falls back to the generic hint when the note is empty", () => {
+ const rules: TemplateRules = { terminology: { level: Requirement.REQUIRED, note: "" } };
+ expect(sectionHint(rules, terminology)).toBe(terminology.hint);
+ });
+
+ it("falls back to the generic hint when there is no rule at all", () => {
+ expect(sectionHint({}, terminology)).toBe(terminology.hint);
+ });
+});
diff --git a/web/src/lib/templates.ts b/web/src/lib/templates.ts
new file mode 100644
index 0000000..8d573e3
--- /dev/null
+++ b/web/src/lib/templates.ts
@@ -0,0 +1,61 @@
+import { Requirement } from "@gen/frames/v1/frame_pb";
+import { SLOT_SECTIONS, type SlotSectionDef } from "@/lib/slot-sections";
+
+// A template's rules, keyed by slot. Shaped like the generated map field so the
+// response can be handed straight in.
+export type TemplateRules = Record;
+
+/**
+ * The sections a template wants an author to see up front: required and
+ * recommended, in schema order. Optional slots stay behind "+ Add section" -
+ * the point of a template is to narrow the decisions, not to reopen all ten.
+ *
+ * Driven off SLOT_SECTIONS rather than the rule map, so order is deterministic
+ * and a rule naming a slot that no longer exists is ignored rather than
+ * crashing the form.
+ */
+export function seededSections(rules: TemplateRules): SlotSectionDef[] {
+ return SLOT_SECTIONS.filter((def) => {
+ const level = rules[def.key]?.level;
+ return level === Requirement.REQUIRED || level === Requirement.RECOMMENDED;
+ });
+}
+
+/**
+ * The template id a publish should carry.
+ *
+ * Create only, and only when a template was actually chosen: the backend uses
+ * the id to check that template's required slots and then discards it, and that
+ * check applies to a new Frame. Edit must never send one, and neither must the
+ * .frame.md import path, whose document came from a file rather than a template.
+ */
+export function publishTemplateID({
+ mode,
+ importing,
+ templateID,
+}: {
+ mode: "create" | "edit";
+ importing: boolean;
+ templateID: string;
+}): string {
+ return mode === "create" && !importing ? templateID : "";
+}
+
+/**
+ * Whether a section may not be removed. A required section that an author
+ * deletes guarantees a publish failure, so the remove control is withheld
+ * rather than offered and then punished.
+ */
+export function isRequiredSection(rules: TemplateRules, key: string): boolean {
+ return rules[key]?.level === Requirement.REQUIRED;
+}
+
+/**
+ * What to show under a section heading. The template author's note beats the
+ * generic per-slot hint: they know why their org wants this section, and the
+ * generic text does not.
+ */
+export function sectionHint(rules: TemplateRules, def: SlotSectionDef): string {
+ const note = rules[def.key]?.note?.trim();
+ return note ? note : def.hint;
+}
diff --git a/web/src/pages/FrameAuthoringPage.create.test.tsx b/web/src/pages/FrameAuthoringPage.create.test.tsx
index 9e6e0b4..9bc561c 100644
--- a/web/src/pages/FrameAuthoringPage.create.test.tsx
+++ b/web/src/pages/FrameAuthoringPage.create.test.tsx
@@ -30,8 +30,15 @@ function pointerClick(el: Element) {
fireEvent.click(el);
}
+// The real create path now starts at the template picker; these tests exercise
+// the authoring form itself, so they render past it with a template already
+// chosen in the URL, same as a user who just picked one.
function renderCreate() {
- render( );
+ render(
+
+
+ ,
+ );
}
async function fillIdentity() {
diff --git a/web/src/pages/FrameAuthoringPage.markdown.test.tsx b/web/src/pages/FrameAuthoringPage.markdown.test.tsx
index 9e7a54a..3ba7e75 100644
--- a/web/src/pages/FrameAuthoringPage.markdown.test.tsx
+++ b/web/src/pages/FrameAuthoringPage.markdown.test.tsx
@@ -47,8 +47,15 @@ beforeEach(() => {
publishMock.mockReset();
});
+// The real create path now starts at the template picker; these tests exercise
+// the authoring form itself, so they render past it with a template already
+// chosen in the URL, same as a user who just picked one.
function renderCreate() {
- return render( );
+ return render(
+
+
+ ,
+ );
}
// Markdown is a secondary mode behind the overflow menu, not a header toggle.
diff --git a/web/src/pages/FrameAuthoringPage.template.test.tsx b/web/src/pages/FrameAuthoringPage.template.test.tsx
new file mode 100644
index 0000000..2cc268b
--- /dev/null
+++ b/web/src/pages/FrameAuthoringPage.template.test.tsx
@@ -0,0 +1,75 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { MemoryRouter, Route, Routes } from "react-router";
+import { expect, it, vi, beforeEach } from "vitest";
+
+const useQueryMock = vi.fn();
+const useMutationMock = vi.fn();
+// Arguments are forwarded so tests can route by which RPC was asked for. Routing
+// by call order instead breaks the moment React re-renders, which it does on
+// every keystroke in the form.
+vi.mock("@connectrpc/connect-query", () => ({
+ useQuery: (...args: unknown[]) => useQueryMock(...args),
+ useMutation: (...args: unknown[]) => useMutationMock(...args),
+ createConnectQueryKey: () => ["k"],
+}));
+vi.mock("@tanstack/react-query", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useQueryClient: () => ({ invalidateQueries: vi.fn() }),
+}));
+
+import { FrameAuthoringPage } from "./FrameAuthoringPage";
+
+const templateList = {
+ isLoading: false,
+ error: null,
+ data: {
+ canManage: false,
+ templates: [
+ { id: "builtin:blank", title: "Blank", description: "Empty.", builtin: true },
+ { id: "builtin:domain-vocabulary", title: "Domain Vocabulary", description: "Terms.", builtin: true },
+ ],
+ },
+};
+
+beforeEach(() => {
+ useQueryMock.mockReturnValue(templateList);
+ useMutationMock.mockReturnValue({ mutateAsync: vi.fn(), isPending: false });
+});
+
+function renderAt(path: string) {
+ return render(
+
+
+ } />
+
+ ,
+ );
+}
+
+it("shows the picker at /frames/new with no template chosen", () => {
+ renderAt("/frames/new");
+ expect(screen.getByRole("heading", { name: /built-in/i })).toBeInTheDocument();
+ // The authoring form must not be on the page yet.
+ expect(screen.queryByLabelText(/frame name/i)).not.toBeInTheDocument();
+});
+
+it("navigates to the seeded form when a template is chosen", async () => {
+ renderAt("/frames/new");
+ await userEvent.click(screen.getByRole("button", { name: /domain vocabulary/i }));
+ // Choosing a template puts it in the URL, so the choice is linkable and a
+ // reload does not lose it.
+ expect(screen.queryByRole("heading", { name: /built-in/i })).not.toBeInTheDocument();
+ expect(screen.getByLabelText(/frame name/i)).toBeInTheDocument();
+});
+
+it("skips the picker when a template is already in the URL", () => {
+ renderAt("/frames/new?template=builtin:blank");
+ expect(screen.queryByRole("heading", { name: /built-in/i })).not.toBeInTheDocument();
+ expect(screen.getByLabelText(/frame name/i)).toBeInTheDocument();
+});
+
+it("skips the picker for the .frame.md import path", () => {
+ renderAt("/frames/new?import=1");
+ expect(screen.queryByRole("heading", { name: /built-in/i })).not.toBeInTheDocument();
+});
diff --git a/web/src/pages/FrameAuthoringPage.tsx b/web/src/pages/FrameAuthoringPage.tsx
index 98cf7ec..5a49244 100644
--- a/web/src/pages/FrameAuthoringPage.tsx
+++ b/web/src/pages/FrameAuthoringPage.tsx
@@ -20,6 +20,7 @@ import { MarkdownSourceEditor } from "@/components/form/MarkdownSourceEditor";
import { DocMetadataHeader } from "@/components/document/DocMetadataHeader";
import { AddSectionMenu } from "@/components/document/AddSectionMenu";
import { PublishDialog } from "@/components/document/PublishDialog";
+import { TemplatePicker } from "@/components/frame/TemplatePicker";
import { Button } from "@/components/ui/button";
import { Alert } from "@/components/ui/alert";
import {
@@ -72,7 +73,7 @@ function SectionEditor({
export function FrameAuthoringPage({ mode }: { mode: "create" | "edit" }) {
const navigate = useNavigate();
const queryClient = useQueryClient();
- const [searchParams] = useSearchParams();
+ const [searchParams, setSearchParams] = useSearchParams();
const [formError, setFormError] = useState(null);
const methods = useForm({
@@ -92,6 +93,14 @@ export function FrameAuthoringPage({ mode }: { mode: "create" | "edit" }) {
const [markdownSource, setMarkdownSource] = useState("");
const [markdownErrors, setMarkdownErrors] = useState([]);
+ const templateID = searchParams.get("template") ?? "";
+ // The picker is the first screen of the create flow. `?import=1` bypasses it:
+ // that path already has its source document, so asking for a starting shape
+ // would be nonsense. Edit mode never sees it - templates apply at creation.
+ const choosingTemplate = mode === "create" && !importing && templateID === "";
+
+ const templates = useQuery(FrameService.method.listFrameTemplates, {});
+
// Sections the author added this session; content-bearing sections are
// always visible regardless (which covers the async edit-mode prefill).
const [added, setAdded] = useState>(new Set());
@@ -300,6 +309,17 @@ export function FrameAuthoringPage({ mode }: { mode: "create" | "edit" }) {
const title = mode === "edit" ? "Edit Frame" : importing ? "Import Frame" : "New Frame";
+ if (choosingTemplate) {
+ return (
+ setSearchParams({ template: id }, { replace: true })}
+ />
+ );
+ }
+
return (
- {error ? (
+ {failed ? (
Templates could not be loaded
- {error}
+ The registry could not be reached.
{onRetry && (
diff --git a/web/src/lib/frame-yaml.test.ts b/web/src/lib/frame-yaml.test.ts
index 0037b88..4c14a19 100644
--- a/web/src/lib/frame-yaml.test.ts
+++ b/web/src/lib/frame-yaml.test.ts
@@ -109,6 +109,38 @@ slots:
expect(parsed.slots.skills).toBeUndefined();
});
+ it("drops a terminology row the author added and left entirely empty", () => {
+ // "+ Add term" appends {term: "", definition: ""}, so an abandoned row is
+ // as easy to produce here as in a list - and the backend refuses it the
+ // same way. A row with one side filled is a real error the author should
+ // see, so it survives to be reported.
+ const doc: FrameDoc = {
+ name: "minimal",
+ description: "d",
+ version: "1.0.0",
+ visibility: "",
+ scope: "",
+ maintainer: "",
+ slots: {
+ terminology: [
+ { term: "Frame", definition: "A scoped context artifact." },
+ { term: " ", definition: "" },
+ { term: "Slot", definition: "" },
+ ],
+ },
+ };
+ const parsed = parseFrameContent(serializeFrameDoc(doc));
+ expect(parsed.slots.terminology).toEqual([
+ { term: "Frame", definition: "A scoped context artifact." },
+ { term: "Slot", definition: "" },
+ ]);
+ });
+
+ it("drops an empty terminology row from a template prefill, leaving no slot behind", () => {
+ const parsed = parseFrameContent(serializeFramePrefill({ slots: { terminology: [{ term: "", definition: "" }] } }));
+ expect(parsed.slots.terminology).toBeUndefined();
+ });
+
it("drops blank list rows from a template prefill too", () => {
const out = serializeFramePrefill({
slots: { rules: ["", "Keep it short"], skills: [] },
diff --git a/web/src/lib/frame-yaml.ts b/web/src/lib/frame-yaml.ts
index be9d13d..81da541 100644
--- a/web/src/lib/frame-yaml.ts
+++ b/web/src/lib/frame-yaml.ts
@@ -64,11 +64,19 @@ function withoutBlanks(items: string[] | undefined): string[] {
return (items ?? []).filter((s) => s.trim() !== "");
}
+// The same rule for terminology, where a row is a pair. Only a row with
+// nothing at all in it is dropped: a half-filled row is a real validation
+// error the author meant to write and should see, not a stray keystroke.
+function withoutEmptyTerms(terms: FrameDoc["slots"]["terminology"]): { term: string; definition: string }[] {
+ return (terms ?? []).filter((t) => t.term.trim() !== "" || t.definition.trim() !== "");
+}
+
// Builds a plain object with keys in schema.go order, omitting empty values,
// so the YAML round-trips through the backend Parse (KnownFields(true)).
function compactSlots(s: FrameDoc["slots"]): Record {
const out: Record = {};
- if (s.terminology && s.terminology.length > 0) out.terminology = s.terminology;
+ const terms = withoutEmptyTerms(s.terminology);
+ if (terms.length > 0) out.terminology = terms;
for (const key of ["rules", "skills", "prompts"] as const) {
const items = withoutBlanks(s[key]);
if (items.length > 0) out[key] = items;
diff --git a/web/src/lib/publish-errors.ts b/web/src/lib/publish-errors.ts
index c4964e2..4e8b5c6 100644
--- a/web/src/lib/publish-errors.ts
+++ b/web/src/lib/publish-errors.ts
@@ -31,6 +31,10 @@ export function mapPublishError(err: unknown): PublishErrorResult {
if (Object.keys(fieldErrors).length > 0) {
return { fieldErrors, formError: null };
}
+ // No violation means a republished version. A violation carrying an empty
+ // field name would land here too and be reported as a version conflict,
+ // which would be wrong - the server never sends one (it names "name"), and
+ // this is the assumption to revisit if that ever changes.
return { fieldErrors: { version: ce.rawMessage || "frame version already exists" }, formError: null };
}
diff --git a/web/src/pages/AdminTemplatesPage.test.tsx b/web/src/pages/AdminTemplatesPage.test.tsx
index 854d4fc..b3e41f7 100644
--- a/web/src/pages/AdminTemplatesPage.test.tsx
+++ b/web/src/pages/AdminTemplatesPage.test.tsx
@@ -230,7 +230,9 @@ it("says why the edit form is missing when its row cannot be read", async () =>
await userEvent.click(orgRow.querySelector('[data-testid="edit-template"]') as HTMLElement);
expect(await screen.findByText(/could not be loaded/i)).toBeInTheDocument();
- expect(screen.getByText('no template "t1"')).toBeInTheDocument();
+ // A fixed sentence rather than the server's, matching this page's own list
+ // error and every other read failure in the app.
+ expect(screen.queryByText(/no template/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/^title$/i)).not.toBeInTheDocument();
});
@@ -246,3 +248,35 @@ it("says so when a stored template's content cannot be decoded", async () => {
expect(await screen.findByText(/could not be read/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/^title$/i)).not.toBeInTheDocument();
});
+
+it("keeps an in-progress edit when a later row fetch fails", async () => {
+ // Same rule as the authoring form: once the dialog has a row and the admin
+ // is typing, a failed refetch must not replace their unsaved edit with an
+ // error screen.
+ const { ConnectError, Code } = await import("@connectrpc/connect");
+ let failing = false;
+ useQueryMock.mockImplementation((method: unknown) => {
+ if (method === FrameService.method.getFrameTemplate) {
+ return {
+ isLoading: false,
+ isFetchedAfterMount: true,
+ data: { template: existingOrgTemplate },
+ error: failing ? new ConnectError("connection lost", Code.Unavailable) : null,
+ };
+ }
+ return { isLoading: false, error: null, isFetchedAfterMount: true, data: templateListData };
+ });
+
+ const { rerender } = renderPage();
+ const orgRow = screen.getByText("Our House Style").closest("li, tr")!;
+ await userEvent.click(orgRow.querySelector('[data-testid="edit-template"]') as HTMLElement);
+ const titleInput = await screen.findByLabelText(/^title$/i);
+ await userEvent.clear(titleInput);
+ await userEvent.type(titleInput, "Renamed Style");
+
+ failing = true;
+ rerender( );
+
+ expect(screen.getByLabelText(/^title$/i)).toHaveValue("Renamed Style");
+ expect(screen.queryByText(/could not be loaded/i)).not.toBeInTheDocument();
+});
diff --git a/web/src/pages/AdminTemplatesPage.tsx b/web/src/pages/AdminTemplatesPage.tsx
index b4cd386..7e594e7 100644
--- a/web/src/pages/AdminTemplatesPage.tsx
+++ b/web/src/pages/AdminTemplatesPage.tsx
@@ -161,7 +161,14 @@ function TemplateFormDialog({
// is what silently reverts a previous edit on the next Save.
if (!rowQ.isFetchedAfterMount) return;
const tmpl = rowQ.data?.template;
- if (!tmpl) return;
+ if (!tmpl) {
+ // A fetch that succeeded while carrying no row: not something the RPC
+ // should produce, but if it does, `ready` would never flip and the
+ // dialog would sit on its skeleton with nothing to explain it. Scheduled
+ // outside the effect body, like the decode failure below.
+ setTimeout(() => setLoadError("The registry returned no content for it."), 0);
+ return;
+ }
try {
const doc = parseFrameContent(tmpl.prefill);
// This effect reacts to the row query resolving (an external system's
@@ -216,17 +223,23 @@ function TemplateFormDialog({
{target.mode === "edit" ? "Edit template" : "New template"}
- {rowQ.error || loadError ? (
+ {!ready ? (
// Seeding waits for a fetch of its own, so anything that stops the
// seed - a failed request, or content that will not decode - has to
- // be said here. Both used to leave the dialog on its loading
- // skeleton for as long as the admin cared to wait.
-
- This template could not be loaded
- {rowQ.error ? rowQ.error.rawMessage : loadError}
-
- ) : !ready ? (
-
+ // be said here; both used to leave this dialog on its skeleton for
+ // as long as the admin cared to wait. Nested inside `!ready` rather
+ // than checked ahead of it, so a refetch that fails after the form
+ // is populated cannot replace an edit in progress with an error.
+ rowQ.error || loadError ? (
+
+ This template could not be loaded
+
+ {loadError ?? "It may have been deleted, or the registry could not be reached."}
+
+
+ ) : (
+
+ )
) : (
{ void methods.handleSubmit(onSubmit)(e); }} className="space-y-6">
diff --git a/web/src/pages/FrameAuthoringPage.template.test.tsx b/web/src/pages/FrameAuthoringPage.template.test.tsx
index e5da6e7..4981816 100644
--- a/web/src/pages/FrameAuthoringPage.template.test.tsx
+++ b/web/src/pages/FrameAuthoringPage.template.test.tsx
@@ -228,8 +228,10 @@ it("explains a failed template list instead of drawing an empty picker", async (
renderAt("/frames/new");
expect(screen.getByText(/could not be loaded/i)).toBeInTheDocument();
- // The bare server text, not the "[internal] …" the code prefix would add.
- expect(screen.getByText("no such table: frame_templates")).toBeInTheDocument();
+ // A fixed sentence, not the server's text: every other read failure in this
+ // app states one, because storage detail is not something a reader can act
+ // on and retrying is.
+ expect(screen.queryByText(/no such table/i)).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /try again/i }));
expect(refetch).toHaveBeenCalled();
});
@@ -262,3 +264,72 @@ it("offers a way out when the chosen template cannot be read", async () => {
await userEvent.click(screen.getByRole("button", { name: /choose a different template/i }));
expect(screen.getByRole("heading", { name: /built-in/i })).toBeInTheDocument();
});
+
+it("keeps an in-progress form when a later fetch of the chosen template fails", async () => {
+ // The template loaded once and the author has been typing. A refetch can
+ // still fail afterwards - reconnect, a token refresh, the template being
+ // deleted in another session - and replacing the form at that point throws
+ // away unsaved work for a template that is demonstrably readable.
+ let failing = false;
+ useQueryMock.mockImplementation((method: unknown) => {
+ if (method === FrameService.method.getFrameTemplate) {
+ return {
+ isLoading: false,
+ isFetchedAfterMount: true,
+ data: { template: vocabularyTemplate },
+ error: failing ? new ConnectError("connection lost", Code.Unavailable) : null,
+ refetch: vi.fn(),
+ };
+ }
+ return templateList;
+ });
+ const { rerender } = renderAt("/frames/new?template=builtin:domain-vocabulary");
+ await userEvent.type(screen.getByLabelText(/frame name/i), "vocab");
+
+ failing = true;
+ rerender(
+
+
+ } />
+
+ ,
+ );
+
+ expect(screen.getByLabelText(/frame name/i)).toHaveValue("vocab");
+ expect(screen.queryByText(/could not be loaded/i)).not.toBeInTheDocument();
+});
+
+it("seeds the create form only from a prefill fetched after this mount", async () => {
+ // The mirror of AdminTemplatesPage's stale-seed test. Without it, deleting
+ // this page's `isFetchedAfterMount` guard breaks no test at all, even though
+ // it is half of the fix for seeding from a cached row.
+ let fetched = false;
+ useQueryMock.mockImplementation((method: unknown) => {
+ if (method === FrameService.method.getFrameTemplate) {
+ return fetched
+ ? { isLoading: false, error: null, isFetchedAfterMount: true, data: { template: orgTemplateWithPrefill } }
+ : {
+ isLoading: false,
+ error: null,
+ isFetchedAfterMount: false,
+ // The cached copy: an earlier prefill for this same id.
+ data: { template: { ...orgTemplateWithPrefill, prefill: new TextEncoder().encode("slots:\n terminology:\n - term: Stale\n definition: From the cache.\n") } },
+ };
+ }
+ return templateList;
+ });
+
+ const { rerender } = renderAt("/frames/new?template=01JORGTEMPLATE0000000000AB");
+ expect(screen.queryByDisplayValue("Stale")).not.toBeInTheDocument();
+
+ fetched = true;
+ rerender(
+
+
+ } />
+
+ ,
+ );
+ expect(await screen.findByDisplayValue("Frame")).toBeInTheDocument();
+});
+
diff --git a/web/src/pages/FrameAuthoringPage.tsx b/web/src/pages/FrameAuthoringPage.tsx
index 22b2e44..6cfce5a 100644
--- a/web/src/pages/FrameAuthoringPage.tsx
+++ b/web/src/pages/FrameAuthoringPage.tsx
@@ -132,7 +132,12 @@ export function FrameAuthoringPage({ mode }: { mode: "create" | "edit" }) {
const chosen = useQuery(
FrameService.method.getFrameTemplate,
{ id: templateID },
- { enabled: publishesTemplate },
+ // `refetchOnMount: "always"` pairs with the isFetchedAfterMount guard on
+ // the seed below - the same pair the template edit dialog uses. Neither
+ // half works alone: without the option, the guard only happens to pass
+ // because no global staleTime is set (web/src/lib/query.ts), and the day
+ // one is added the seed would silently stop running.
+ { enabled: publishesTemplate, refetchOnMount: "always" },
);
const rules: TemplateRules = (chosen.data?.template?.fieldRules ?? {}) as TemplateRules;
@@ -166,9 +171,14 @@ export function FrameAuthoringPage({ mode }: { mode: "create" | "edit" }) {
// path exactly: getFrameTemplate -> parseFrameContent -> docToForm are the
// same three steps that open an existing Frame, so there is no second
// parsing path to keep in step.
- const [seeded, setSeeded] = useState(false);
+ // Which template seeded this form, not merely whether one did. Returning to
+ // the picker without a remount is possible (the load-failure screen below
+ // clears the id), and a boolean latch would then hold the first template's
+ // content while every publish carried the second one's id - checking its
+ // required slots against content the author never saw it ask for.
+ const [seededFor, setSeededFor] = useState(null);
useEffect(() => {
- if (mode !== "create" || templateID === "" || seeded) return;
+ if (mode !== "create" || templateID === "" || seededFor === templateID) return;
// Only a prefill this mount fetched may seed the form. React Query returns
// a cached row for the key synchronously and refetches behind it, so an
// edit made in the admin page since it was cached would be invisible here.
@@ -198,11 +208,11 @@ export function FrameAuthoringPage({ mode }: { mode: "create" | "edit" }) {
} catch {
setTimeout(() => setFormError("This template's starting content could not be loaded."), 0);
}
- setSeeded(true);
+ setSeededFor(templateID);
// seed once per template choice; re-running on every `rules` identity
// change would fight the author's own edits after the initial seed
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [chosen.data, chosen.isFetchedAfterMount, mode, templateID, seeded]);
+ }, [chosen.data, chosen.isFetchedAfterMount, mode, templateID, seededFor]);
const slots = useWatch({ control: methods.control, name: "slots" }) as
| AuthoringForm["slots"]
@@ -404,7 +414,7 @@ export function FrameAuthoringPage({ mode }: { mode: "create" | "edit" }) {
// A failed list would otherwise draw the heading and no cards, which
// reads as "your org has no templates" rather than "the request
// failed" - and the built-ins are compiled in, so it is never true.
- error={templates.error ? templates.error.rawMessage : null}
+ failed={templates.error !== null}
onRetry={() => void templates.refetch()}
// The choice goes in the URL rather than component state, so it is
// linkable and survives a reload, matching how `?import=1` works.
@@ -417,14 +427,21 @@ export function FrameAuthoringPage({ mode }: { mode: "create" | "edit" }) {
// stays in the URL and every publish carries it, so the server refuses each
// one for a template the author cannot see. Say so, and offer the two ways
// out - retry, or go back and pick another.
- if (chosen.error) {
+ //
+ // Only while there is nothing to show. Once the prefill has seeded a form the
+ // author is typing into, this template is demonstrably readable and the id is
+ // demonstrably good; a later failure is a refetch (reconnect, a token
+ // refresh, a delete in another session), and replacing the form over one
+ // would throw away unsaved work to report a problem that no longer blocks
+ // anything.
+ if (chosen.error && seededFor !== templateID) {
return (
This template could not be loaded
- {chosen.error.rawMessage}
- It may have been deleted, or belong to another organization.
+ It may have been deleted, it may belong to another organization, or the registry
+ could not be reached.
From da6ea1fabb28c2304e08baed1e0818e6c0676ae3 Mon Sep 17 00:00:00 2001
From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com>
Date: Wed, 9 Sep 2026 17:37:23 +0200
Subject: [PATCH 39/43] fix(web): never seed a form from a query that failed,
and hold both slot forms to one content schema
A failed fetch satisfies `isFetchedAfterMount` - query-core counts data
updates and error updates alike - and its error reducer keeps whatever was
already cached, flagging it invalidated rather than clearing it. So a forced
refetch that failed over an already-cached row still looked, to both seed
effects, exactly like fresh data arriving: they seeded from the stale row,
latched, and suppressed the error for the rest of the session. That is the
bug this mechanism exists to refuse, reached through the error path instead
of the cache path. Both effects now refuse to seed while `error` is set.
The dialog's "returned no content" branch was reached by that same path, so
it also overrode the accurate message on every ordinary failure - a deleted
template reported as empty content. It now speaks only about a fetch that
actually succeeded, and its test asserts which sentence appears rather than
only that the old one does not.
The template dialog also validated slot content with the decode schema
rather than the authoring rules, so an admin could save an incomplete
terminology row. Serialization drops rows that are entirely empty, so a
violation the server reported could name an index that no longer matched the
form. The strict rules move to `contentSlotsSchema`, both forms resolve
against it, and the decode schema is unexported with the split written down:
one shape parses what is stored, the other governs what a human may submit -
the same division `Parse` and `contentErrors` make on the server.
AGENTS.md records the three invariants this round had to re-derive: the four
options that make a latching seed correct, the two-schema split, and read
versus write error text.
---
AGENTS.md | 24 ++++++++-
web/src/lib/authoring-schema.ts | 11 +++-
web/src/lib/frame-yaml.ts | 17 +++---
web/src/pages/AdminTemplatesPage.test.tsx | 52 +++++++++++++++++++
web/src/pages/AdminTemplatesPage.tsx | 29 ++++++++---
.../FrameAuthoringPage.template.test.tsx | 32 ++++++++++++
web/src/pages/FrameAuthoringPage.tsx | 11 +++-
7 files changed, 159 insertions(+), 17 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index d5fd068..fb6d407 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -96,7 +96,12 @@ store.Repository**.
click. `Check` reports required slots a document leaves empty; `publish` merges those with the
schema's own violations so an author sees everything at once. Checked on create only: nothing
is recorded on the Frame, so `required` is an authoring aid rather than ongoing governance.
- See `docs/adr/0001-frame-templates-are-not-frames.md`.
+ See `docs/adr/0001-frame-templates-are-not-frames.md`. A prefill is held to the same content
+ rules a Frame is (`contentErrors`), but at the write boundary (`validateTemplateInput`), never
+ in `ParsePrefill`: that decoder is also the read path (`rowToTemplate` runs every stored row
+ through it), so a rule enforced there would make an already-stored row unreadable rather than
+ merely unpublishable. Starters arrive through two doors - the write RPCs and `loadBuiltins` at
+ package init - and a content rule has to guard both.
- `framemd.go` - the Frame Spec v0.2 `.frame.md` codec (YAML frontmatter plus one `##` section per
slot). Round-trip fidelity matters: `examples/*.yaml` and `examples/*.frame.md` are checked-in
conformance fixtures asserted by `examples_test.go`.
@@ -137,6 +142,23 @@ store.Repository**.
`//go:embed` still compiles on a clean checkout. Auth guards live in `web/src/app/`
(`RequireAuth`, `RequireMembership`, `RequireAdmin`); pages in `web/src/pages/`; RPC transport and
domain helpers in `web/src/lib/`.
+ - Seeding a form from server data comes in two shapes. A form that may re-seed keys its `reset`
+ on an identity that changes when the data does (the authoring page's edit path keys on
+ `version.digest`). A form that seeds once and then belongs to the author - a template prefill,
+ the template edit dialog - must pin `refetchOnMount: "always"`, gate the seed on
+ `isFetchedAfterMount`, refuse to seed while `error` is set, and latch on what it seeded from
+ rather than on a boolean. All four go together: React Query hands back a cached row
+ synchronously and refetches behind it, counts a *failed* fetch in `isFetchedAfterMount`, and
+ never clears cached data on error - so any one of them missing is a form that quietly seeds
+ from a stale row and then saves it back.
+ - Two schemas cover the same slot keys on purpose. `frame-yaml.ts` decodes whatever is stored
+ (lenient, because rows predate rules); `contentSlotsSchema` in `authoring-schema.ts` is what a
+ human may submit, and every form that edits slot content resolves against it. The backend
+ splits the same two jobs the same way (`Parse` vs `contentErrors`).
+ - Error text splits by direction. A failed read states a fixed sentence - the server's message
+ there is storage or wiring detail and retrying is all the reader can do. A failed write shows
+ `ConnectError.rawMessage`, because it names what the caller must change; when it carries
+ `FieldViolations`, it belongs on the input instead.
- `cli/` is the `frames` binary (Cobra plus Viper; config at `~/.config/frames/config.yaml`, env
prefix `FRAMES_`, device-flow login).
- `chart/` deploys onto a Nebari cluster; the nebari-operator provisions routing, TLS, and the OIDC
diff --git a/web/src/lib/authoring-schema.ts b/web/src/lib/authoring-schema.ts
index aff6993..b805380 100644
--- a/web/src/lib/authoring-schema.ts
+++ b/web/src/lib/authoring-schema.ts
@@ -10,7 +10,14 @@ const termSchema = z.object({
const nonEmptyList = z.array(z.string().trim().min(1, "must not be empty")).optional();
-const slotsSchema = z.object({
+// The content rules both authoring surfaces enforce, exported because a
+// template's prefill is edited with the same editors and stored through the
+// same schema as a Frame's slots: `frame-yaml.ts` carries the decode shape
+// (deliberately lenient, since it also has to read whatever is already
+// stored), and this is the shape a human is allowed to submit. Backend
+// counterpart: contentErrors in backend/internal/frames/validate.go, shared by
+// a publish and a template write for the same reason.
+export const contentSlotsSchema = z.object({
terminology: z
.array(termSchema)
.optional()
@@ -50,7 +57,7 @@ export const authoringSchema = z.object({
maintainer: z.string(),
extends: z.array(extendSchema).optional(),
excludes: z.array(z.string().trim().min(1)).optional(),
- slots: slotsSchema,
+ slots: contentSlotsSchema,
}) satisfies z.ZodType
;
// Resolver schema for the form: includes the version-scoped changelog. Used so
diff --git a/web/src/lib/frame-yaml.ts b/web/src/lib/frame-yaml.ts
index 81da541..af4e01c 100644
--- a/web/src/lib/frame-yaml.ts
+++ b/web/src/lib/frame-yaml.ts
@@ -1,13 +1,18 @@
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import { z } from "zod";
-// Mirrors backend/internal/frames/schema.go. Keep in sync with that file:
-// it is the canonical Frame content schema. Exported because it is also the
-// content-only shape a template's prefill validates against - the ten slot
-// keys should only ever be listed once in TypeScript.
-export const termSchema = z.object({ term: z.string(), definition: z.string() });
+// Mirrors backend/internal/frames/schema.go. Keep in sync with that file: it is
+// the canonical Frame content schema.
+//
+// Deliberately lenient about values - a term may decode empty here - because
+// this is the shape everything already stored has to parse through, including
+// rows written before a rule existed. What a human is allowed to submit is a
+// stricter schema over the same keys: contentSlotsSchema in authoring-schema.ts,
+// which both the authoring form and the template dialog resolve against. The
+// backend splits the same two jobs the same way (Parse vs contentErrors).
+const termSchema = z.object({ term: z.string(), definition: z.string() });
-export const slotsSchema = z.object({
+const slotsSchema = z.object({
terminology: z.array(termSchema).optional(),
rules: z.array(z.string()).optional(),
skills: z.array(z.string()).optional(),
diff --git a/web/src/pages/AdminTemplatesPage.test.tsx b/web/src/pages/AdminTemplatesPage.test.tsx
index b3e41f7..c286ed9 100644
--- a/web/src/pages/AdminTemplatesPage.test.tsx
+++ b/web/src/pages/AdminTemplatesPage.test.tsx
@@ -280,3 +280,55 @@ it("keeps an in-progress edit when a later row fetch fails", async () => {
expect(screen.getByLabelText(/^title$/i)).toHaveValue("Renamed Style");
expect(screen.queryByText(/could not be loaded/i)).not.toBeInTheDocument();
});
+
+it("does not seed the edit form from a cached row when the fetch failed, and says which failure it was", async () => {
+ // Same query-core shape as the authoring page's test: a failed fetch counts
+ // as "fetched after mount" and leaves the cached row in place.
+ const { ConnectError, Code } = await import("@connectrpc/connect");
+ useQueryMock.mockImplementation((method: unknown) => {
+ if (method === FrameService.method.getFrameTemplate) {
+ return {
+ isLoading: false,
+ isFetchedAfterMount: true,
+ error: new ConnectError("connection lost", Code.Unavailable),
+ data: { template: { ...existingOrgTemplate, title: "Stale Cached Title" } },
+ };
+ }
+ return { isLoading: false, error: null, isFetchedAfterMount: true, data: templateListData };
+ });
+ renderPage();
+ const orgRow = screen.getByText("Our House Style").closest("li, tr")!;
+ await userEvent.click(orgRow.querySelector('[data-testid="edit-template"]') as HTMLElement);
+
+ expect(await screen.findByText(/could not be loaded/i)).toBeInTheDocument();
+ expect(screen.queryByDisplayValue("Stale Cached Title")).not.toBeInTheDocument();
+ // The failure has to be reported as the failure it was: "no content" is a
+ // different claim, and asserting the exact sentence is what stops this test
+ // from passing whichever one appears.
+ expect(screen.getByText(/may have been deleted, or the registry could not be reached/i)).toBeInTheDocument();
+ expect(screen.queryByText(/returned no content/i)).not.toBeInTheDocument();
+});
+
+it("refuses to save a template whose terminology row is incomplete", async () => {
+ // Client-side, per row, rather than a server round trip: serialization drops
+ // rows that are entirely empty, so an incomplete row reported by the server
+ // can carry an index that no longer matches the form. The authoring form has
+ // always held content to these rules; the template dialog now does too.
+ // beforeEach re-stubs the mutation mocks but does not clear their call
+ // history, so this test counts only its own calls.
+ createMock.mockClear();
+ renderPage();
+ await userEvent.click(screen.getByRole("button", { name: /new template/i }));
+ await userEvent.type(screen.getByLabelText(/^title$/i), "Vocabulary");
+ await userEvent.type(screen.getByLabelText(/^description$/i), "Our terms");
+ await userEvent.click(screen.getByRole("button", { name: /add term/i }));
+ // Term filled, definition left blank: a row the admin meant to write and
+ // did not finish, which is exactly the row that must not be dropped
+ // silently and must not travel to the server either.
+ await userEvent.type(screen.getByPlaceholderText("Term"), "Frame");
+
+ await userEvent.click(screen.getByRole("button", { name: /^create$/i }));
+
+ expect(await screen.findByText(/must not be empty/i)).toBeInTheDocument();
+ expect(createMock).not.toHaveBeenCalled();
+});
diff --git a/web/src/pages/AdminTemplatesPage.tsx b/web/src/pages/AdminTemplatesPage.tsx
index 7e594e7..a543016 100644
--- a/web/src/pages/AdminTemplatesPage.tsx
+++ b/web/src/pages/AdminTemplatesPage.tsx
@@ -10,7 +10,8 @@ import { Requirement } from "@gen/frames/v1/frame_pb";
import type { FrameTemplateSummary } from "@gen/frames/v1/frame_pb";
import { Plus, Pencil, Trash2 } from "lucide-react";
import { SLOT_SECTIONS, type SlotSectionDef } from "@/lib/slot-sections";
-import { parseFrameContent, serializeFramePrefill, slotsSchema, type FrameDoc } from "@/lib/frame-yaml";
+import { parseFrameContent, serializeFramePrefill, type FrameDoc } from "@/lib/frame-yaml";
+import { contentSlotsSchema } from "@/lib/authoring-schema";
import { TerminologyEditor } from "@/components/form/TerminologyEditor";
import { ListEditor } from "@/components/form/ListEditor";
import { MarkdownField } from "@/components/form/MarkdownField";
@@ -33,7 +34,11 @@ const ruleEntrySchema = z.object({
const templateFormSchema = z.object({
title: z.string().trim().min(1, "must not be empty"),
description: z.string().trim().min(1, "must not be empty"),
- slots: slotsSchema,
+ // The same content rules the authoring form applies, not frame-yaml's decode
+ // schema: a blank row an admin abandoned would otherwise reach the server,
+ // come back as a flattened message, and - since serialization drops empty
+ // rows - name a row index that no longer matches what is on screen.
+ slots: contentSlotsSchema,
rules: z.record(z.string(), ruleEntrySchema).optional(),
});
type TemplateForm = z.infer;
@@ -160,12 +165,22 @@ function TemplateFormDialog({
// and refetches behind it. Seeding from that copy and then latching `ready`
// is what silently reverts a previous edit on the next Save.
if (!rowQ.isFetchedAfterMount) return;
+ // A fetch that FAILED satisfies the guard above: query-core counts data
+ // updates and error updates alike (isFetchedAfterMount is
+ // `dataUpdateCount > initial || errorUpdateCount > initial`), and its error
+ // reducer keeps whatever data was already cached, flagging it invalidated
+ // rather than clearing it. So without this, a forced refetch that fails
+ // over an already-cached row would seed from that row - the stale content
+ // this whole mechanism exists to refuse - and then latch, hiding the error
+ // for the rest of the session.
+ if (rowQ.error) return;
const tmpl = rowQ.data?.template;
if (!tmpl) {
- // A fetch that succeeded while carrying no row: not something the RPC
- // should produce, but if it does, `ready` would never flip and the
- // dialog would sit on its skeleton with nothing to explain it. Scheduled
- // outside the effect body, like the decode failure below.
+ // A fetch that succeeded while carrying no row - the error case returned
+ // above, so this claim is only ever made about a successful fetch. Not
+ // something the RPC should produce, but if it does, `ready` would never
+ // flip and the dialog would sit on its skeleton with nothing to explain
+ // it. Scheduled outside the effect body, like the decode failure below.
setTimeout(() => setLoadError("The registry returned no content for it."), 0);
return;
}
@@ -189,7 +204,7 @@ function TemplateFormDialog({
setTimeout(() => setLoadError("Its saved content could not be read."), 0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [target.mode, ready, rowQ.data, rowQ.isFetchedAfterMount]);
+ }, [target.mode, ready, rowQ.data, rowQ.error, rowQ.isFetchedAfterMount]);
const busy = createM.isPending || updateM.isPending;
diff --git a/web/src/pages/FrameAuthoringPage.template.test.tsx b/web/src/pages/FrameAuthoringPage.template.test.tsx
index 4981816..14d25db 100644
--- a/web/src/pages/FrameAuthoringPage.template.test.tsx
+++ b/web/src/pages/FrameAuthoringPage.template.test.tsx
@@ -333,3 +333,35 @@ it("seeds the create form only from a prefill fetched after this mount", async (
expect(await screen.findByDisplayValue("Frame")).toBeInTheDocument();
});
+
+it("does not seed from a cached prefill when the fetch that ran on mount failed", async () => {
+ // query-core counts a FAILED fetch in isFetchedAfterMount too
+ // (queryObserver: dataUpdateCount > initial || errorUpdateCount > initial),
+ // and its error reducer keeps whatever data was already cached. So this is
+ // the shape a forced refetch failure produces over a row an admin page had
+ // open moments earlier - and seeding from it would silently restore exactly
+ // the stale-content bug the guard exists to prevent, then latch and hide the
+ // error.
+ useQueryMock.mockImplementation((method: unknown) => {
+ if (method === FrameService.method.getFrameTemplate) {
+ return {
+ isLoading: false,
+ isFetchedAfterMount: true,
+ error: new ConnectError("connection lost", Code.Unavailable),
+ data: {
+ template: {
+ ...orgTemplateWithPrefill,
+ prefill: new TextEncoder().encode("slots:\n terminology:\n - term: Stale\n definition: From the cache.\n"),
+ },
+ },
+ refetch: vi.fn(),
+ };
+ }
+ return templateList;
+ });
+ renderAt("/frames/new?template=01JORGTEMPLATE0000000000AB");
+
+ expect(screen.queryByDisplayValue("Stale")).not.toBeInTheDocument();
+ // Nothing was seeded, so this is still the dead end it looks like.
+ expect(screen.getByText(/could not be loaded/i)).toBeInTheDocument();
+});
diff --git a/web/src/pages/FrameAuthoringPage.tsx b/web/src/pages/FrameAuthoringPage.tsx
index 6cfce5a..b982f92 100644
--- a/web/src/pages/FrameAuthoringPage.tsx
+++ b/web/src/pages/FrameAuthoringPage.tsx
@@ -183,6 +183,15 @@ export function FrameAuthoringPage({ mode }: { mode: "create" | "edit" }) {
// a cached row for the key synchronously and refetches behind it, so an
// edit made in the admin page since it was cached would be invisible here.
if (!chosen.isFetchedAfterMount) return;
+ // A fetch that FAILED satisfies the guard above: query-core counts data
+ // updates and error updates alike (isFetchedAfterMount is
+ // `dataUpdateCount > initial || errorUpdateCount > initial`), and its error
+ // reducer keeps whatever data was already cached, flagging it invalidated
+ // rather than clearing it. So without this, a forced refetch that fails
+ // over an already-cached row would seed from that row - the stale content
+ // this whole mechanism exists to refuse - and then latch, hiding the error
+ // for the rest of the session.
+ if (chosen.error) return;
const tmpl = chosen.data?.template;
if (!tmpl) return;
try {
@@ -212,7 +221,7 @@ export function FrameAuthoringPage({ mode }: { mode: "create" | "edit" }) {
// seed once per template choice; re-running on every `rules` identity
// change would fight the author's own edits after the initial seed
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [chosen.data, chosen.isFetchedAfterMount, mode, templateID, seededFor]);
+ }, [chosen.data, chosen.error, chosen.isFetchedAfterMount, mode, templateID, seededFor]);
const slots = useWatch({ control: methods.control, name: "slots" }) as
| AuthoringForm["slots"]
From 896231cb373c88e5e47acf4e7887763af64c539e Mon Sep 17 00:00:00 2001
From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com>
Date: Wed, 9 Sep 2026 17:56:53 +0200
Subject: [PATCH 40/43] fix(web): show an error that blocks a save, and state
the seeding rule once
An array-level rule reaches react-hook-form nested under `.root`
whenever that path has registered children - @hookform/resolvers does that
in toNestErrors - and the field reader only looked at `.message`. So the
duplicate-term refine produced an error that rendered nowhere and a form
that refused to submit: a Save button that did nothing at all. Sharing the
authoring content rules with the template dialog last commit extended that
silence to a second surface, where duplicates had simply saved before.
Reading the nested message fixes both, including the authoring form, where
it has been silent since the refine was written.
The rest is consolidation of three rounds of patches:
- `freshData()` names the rule both seed effects were open-coding, with the
library reasoning in one place instead of the same eight comment lines in
two files. Both effects are now two lines and read as one intent.
- A prefill that will not decode no longer latches as seeded. It seeded
nothing, so calling it seeded left the author on a defaults-only form with
the id still in the URL and still sent by every publish - required slots
checked against content they never saw. It is a dead end with the same two
ways out as a failed fetch.
- The edit dialog is keyed per target, so "mounted per target" is structural
rather than a consequence of the dialog being modal. That is what makes a
boolean latch correct there, and AGENTS.md now says which latch belongs
where instead of asserting one rule for both.
- The dialog's "returned no content" branch is gone: `GetFrameTemplate`
cannot produce that state, no test exercised it, and the error alert
already covers every failure that is reachable.
- The picker no longer blames the registry for being unreachable. A client
cannot tell that from a registry that answered with an error, and the case
that prompted the finding - a database missing the templates table - is the
second.
- The built-ins content check was a call that could never fire. Replaced with
a reflective test in the idiom internal/mcp already uses, which fails with
instructions the day `builtinFile` gains a prefill key.
- The scaffold header carries the same two commands in three fewer lines,
since these comments persist into published content by design.
---
AGENTS.md | 18 +++---
backend/internal/frames/builtins.go | 14 +----
backend/internal/frames/builtins_test.go | 29 +++++++++-
cli/cmd/template.go | 19 +++---
web/src/components/form/FieldError.tsx | 15 ++++-
web/src/components/frame/TemplatePicker.tsx | 12 ++--
web/src/lib/query-freshness.test.ts | 34 +++++++++++
web/src/lib/query-freshness.ts | 29 ++++++++++
web/src/pages/AdminTemplatesPage.test.tsx | 25 ++++++++
web/src/pages/AdminTemplatesPage.tsx | 55 ++++++------------
.../FrameAuthoringPage.template.test.tsx | 26 +++++++++
web/src/pages/FrameAuthoringPage.tsx | 58 ++++++++-----------
12 files changed, 228 insertions(+), 106 deletions(-)
create mode 100644 web/src/lib/query-freshness.test.ts
create mode 100644 web/src/lib/query-freshness.ts
diff --git a/AGENTS.md b/AGENTS.md
index fb6d407..f2ad50f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -101,7 +101,9 @@ store.Repository**.
in `ParsePrefill`: that decoder is also the read path (`rowToTemplate` runs every stored row
through it), so a rule enforced there would make an already-stored row unreadable rather than
merely unpublishable. Starters arrive through two doors - the write RPCs and `loadBuiltins` at
- package init - and a content rule has to guard both.
+ package init. Built-ins carry rules and notes but no content: `builtinFile` has no `prefill`
+ key, and `TestBuiltinFileCarriesNoPrefill` fails if one is added without running the content
+ check in `loadBuiltins`, so that door cannot open unguarded.
- `framemd.go` - the Frame Spec v0.2 `.frame.md` codec (YAML frontmatter plus one `##` section per
slot). Round-trip fidelity matters: `examples/*.yaml` and `examples/*.frame.md` are checked-in
conformance fixtures asserted by `examples_test.go`.
@@ -145,12 +147,14 @@ store.Repository**.
- Seeding a form from server data comes in two shapes. A form that may re-seed keys its `reset`
on an identity that changes when the data does (the authoring page's edit path keys on
`version.digest`). A form that seeds once and then belongs to the author - a template prefill,
- the template edit dialog - must pin `refetchOnMount: "always"`, gate the seed on
- `isFetchedAfterMount`, refuse to seed while `error` is set, and latch on what it seeded from
- rather than on a boolean. All four go together: React Query hands back a cached row
- synchronously and refetches behind it, counts a *failed* fetch in `isFetchedAfterMount`, and
- never clears cached data on error - so any one of them missing is a form that quietly seeds
- from a stale row and then saves it back.
+ the template edit dialog - must pin `refetchOnMount: "always"` on the query and take its data
+ from `freshData()` (`web/src/lib/query-freshness.ts`), which is where the reasoning lives:
+ React Query returns a cached row synchronously and refetches behind it, counts a *failed*
+ fetch in `isFetchedAfterMount`, and never clears cached data on error. Where the latch lives
+ depends on where the form's identity comes from: the template edit dialog is keyed and mounted
+ per target, so a boolean is its identity, while the authoring page's template comes from
+ `?template=` and can change while the page stays mounted, so it latches on the id it seeded
+ from.
- Two schemas cover the same slot keys on purpose. `frame-yaml.ts` decodes whatever is stored
(lenient, because rows predate rules); `contentSlotsSchema` in `authoring-schema.ts` is what a
human may submit, and every form that edits slot content resolves against it. The backend
diff --git a/backend/internal/frames/builtins.go b/backend/internal/frames/builtins.go
index b64d2c2..77e4741 100644
--- a/backend/internal/frames/builtins.go
+++ b/backend/internal/frames/builtins.go
@@ -84,19 +84,9 @@ func loadBuiltins() ([]Template, error) {
return nil, fmt.Errorf("%s: field_rules[%s] has no note", name, key)
}
}
- tmpl := Template{
+ out = append(out, Template{
ID: f.ID, Title: f.Title, Description: f.Description, FieldRules: f.FieldRules,
- }
- // Starters reach the system through two doors - these files and the
- // write RPCs - and a content rule that guards only one is a rule with a
- // hole in it. A no-op while builtinFile carries no prefill key, but it
- // is the door that has to fail at startup rather than at a user's first
- // click (docs/adr/0001-frame-templates-are-not-frames.md), so the check
- // belongs here before there is anything for it to catch.
- if errs := contentErrors(&tmpl.Prefill.Slots, tmpl.Prefill.Extends); len(errs) > 0 {
- return nil, fmt.Errorf("%s: %w", name, &ValidationError{Errors: errs})
- }
- out = append(out, tmpl)
+ })
}
// Blank leads: it is the "no template" escape hatch and must be easy to
// find. Everything else keeps filename order, which is alphabetical by
diff --git a/backend/internal/frames/builtins_test.go b/backend/internal/frames/builtins_test.go
index 9aaf9fd..d98a876 100644
--- a/backend/internal/frames/builtins_test.go
+++ b/backend/internal/frames/builtins_test.go
@@ -1,6 +1,10 @@
package frames
-import "testing"
+import (
+ "reflect"
+ "strings"
+ "testing"
+)
func TestBuiltinTemplatesLoad(t *testing.T) {
got := BuiltinTemplates()
@@ -104,3 +108,26 @@ func TestBuiltinTemplatesAreCopiedPerCall(t *testing.T) {
}
}
}
+
+// Built-ins are the other door a starter can arrive through, and they skip the
+// write RPCs entirely - so the content rules a prefill is held to
+// (validateTemplateInput) would not apply to one shipped in a builtin file.
+// That is safe only because builtinFile has no prefill key: built-ins carry
+// rules and notes, never content. This asserts that premise structurally
+// rather than trusting a comment, in the same idiom internal/mcp uses to pin
+// its input structs to the slot table.
+//
+// If this fails, you added a prefill to built-in templates. Run contentErrors
+// over it in loadBuiltins before appending, so a malformed starter fails at
+// startup rather than at an author's first click
+// (docs/adr/0001-frame-templates-are-not-frames.md).
+func TestBuiltinFileCarriesNoPrefill(t *testing.T) {
+ ft := reflect.TypeOf(builtinFile{})
+ for i := range ft.NumField() {
+ field := ft.Field(i)
+ tag, name := field.Tag.Get("yaml"), strings.ToLower(field.Name)
+ if strings.Contains(tag, "prefill") || strings.Contains(name, "prefill") {
+ t.Fatalf("builtinFile now has a prefill field (%s); loadBuiltins must validate it", field.Name)
+ }
+ }
+}
diff --git a/cli/cmd/template.go b/cli/cmd/template.go
index 4af8eb3..15343c4 100644
--- a/cli/cmd/template.go
+++ b/cli/cmd/template.go
@@ -67,16 +67,15 @@ func scaffoldComment(t *framesv1.FrameTemplate) string {
if t.Description != "" {
fmt.Fprintf(&b, "# %s\n", t.Description)
}
- b.WriteString("#\n# Fill in name, description, and version, then publish the first version with:\n")
- b.WriteString("# frames publish --dir . --template " + t.Id + "\n")
- // --template asserts a new Frame (it is what makes the requirement check
- // run at all), so the server refuses it once the Frame exists. Naming only
- // the first command would leave an author following a header that stops
- // working - and, since these comments persist into the published content,
- // stops working permanently.
- b.WriteString("#\n# --template checks this template's requirements and asserts the Frame is new,\n")
- b.WriteString("# so publish every later version without it:\n")
- b.WriteString("# frames publish --dir .\n")
+ // Both commands, because --template asserts a new Frame: it is what makes
+ // the requirement check run, and the server refuses it once the Frame
+ // exists. Naming only the first would leave an author following a header
+ // that stops working - permanently, since these lines persist into the
+ // published content (docs/adr/0001-frame-templates-are-not-frames.md). Kept
+ // to two lines for the same reason.
+ b.WriteString("#\n# Fill in name, description, and version, then publish:\n")
+ b.WriteString("# frames publish --dir . --template " + t.Id + " # the first version\n")
+ b.WriteString("# frames publish --dir . # every version after\n")
// Required first: those are what will actually refuse the publish. Slot
// keys are sorted rather than walked in schema order, because that order
diff --git a/web/src/components/form/FieldError.tsx b/web/src/components/form/FieldError.tsx
index f96403e..f11a358 100644
--- a/web/src/components/form/FieldError.tsx
+++ b/web/src/components/form/FieldError.tsx
@@ -2,10 +2,21 @@ import { useFormContext, get } from "react-hook-form";
// Reads the message at a react-hook-form error path. Paths are dotted and may
// index arrays ("slots.terminology.2.definition"), which is what get() handles.
+//
+// The `root` fallback is not an alternative spelling: @hookform/resolvers
+// (toNestErrors) nests an error under `.root` whenever that path has
+// registered children, which is what a rule about a whole array produces - a
+// duplicate-term refine over `slots.terminology`, say, while
+// `slots.terminology.0.term` is registered. Reading only `.message` there
+// finds an object with none, so the form refuses to submit and shows nothing:
+// a Save button that silently does nothing at all.
export function useFieldError(name: string): string | undefined {
const { formState } = useFormContext();
- const e = get(formState.errors, name) as { message?: string } | undefined;
- return typeof e?.message === "string" ? e.message : undefined;
+ const e = get(formState.errors, name) as
+ | { message?: string; root?: { message?: string } }
+ | undefined;
+ const message = typeof e?.message === "string" ? e.message : e?.root?.message;
+ return typeof message === "string" ? message : undefined;
}
// Stable id for the error text, so an input can point at it via
diff --git a/web/src/components/frame/TemplatePicker.tsx b/web/src/components/frame/TemplatePicker.tsx
index edad103..1ff115e 100644
--- a/web/src/components/frame/TemplatePicker.tsx
+++ b/web/src/components/frame/TemplatePicker.tsx
@@ -1,6 +1,6 @@
import type { FrameTemplateSummary } from "@gen/frames/v1/frame_pb";
import { Button } from "@/components/ui/button";
-import { Alert, AlertTitle, AlertDescription, AlertAction } from "@/components/ui/alert";
+import { Alert, AlertTitle, AlertAction } from "@/components/ui/alert";
// Org templates and built-ins are shown in separate groups rather than merged
// or shadowed. An org template sharing a built-in's title sits beside it: a
@@ -50,9 +50,8 @@ export function TemplatePicker({
// ("there are no templates") to a question that was never answered.
//
// A flag rather than the server's message: read failures across this app
- // state a fixed sentence (AdminFramesPage, AdminMembersPage, and this page's
- // own list error), because the text on a failed read is storage or wiring
- // detail and retrying is the only thing the reader can act on.
+ // state a fixed sentence, because the text on a failed read is storage or
+ // wiring detail and retrying is the only thing the reader can act on.
failed?: boolean;
onRetry?: () => void;
}) {
@@ -67,8 +66,11 @@ export function TemplatePicker({
{failed ? (
+ {/* No cause is stated: the client cannot tell an unreachable
+ registry from one that answered with an error, and the case that
+ prompted this - a database missing the templates table - is the
+ second. */}
Templates could not be loaded
- The registry could not be reached.
{onRetry && (
diff --git a/web/src/lib/query-freshness.test.ts b/web/src/lib/query-freshness.test.ts
new file mode 100644
index 0000000..4a95df2
--- /dev/null
+++ b/web/src/lib/query-freshness.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest";
+import { ConnectError, Code } from "@connectrpc/connect";
+import { freshData } from "./query-freshness";
+
+describe("freshData", () => {
+ const row = { title: "Our House Style" };
+
+ it("returns data a fetch delivered during this mount", () => {
+ expect(freshData({ data: row, error: null, isFetchedAfterMount: true })).toBe(row);
+ });
+
+ it("withholds a cached row no fetch has confirmed yet", () => {
+ // The state on the first render after mount: cached data present, the
+ // forced refetch still in flight.
+ expect(freshData({ data: row, error: null, isFetchedAfterMount: false })).toBeUndefined();
+ });
+
+ it("withholds a cached row when the fetch that ran failed", () => {
+ // query-core counts a failed fetch in isFetchedAfterMount and leaves the
+ // cached data in place, so this state is indistinguishable from success
+ // without checking `error`.
+ expect(
+ freshData({
+ data: row,
+ error: new ConnectError("connection lost", Code.Unavailable),
+ isFetchedAfterMount: true,
+ }),
+ ).toBeUndefined();
+ });
+
+ it("has nothing to give when a fresh fetch carried nothing", () => {
+ expect(freshData({ data: undefined, error: null, isFetchedAfterMount: true })).toBeUndefined();
+ });
+});
diff --git a/web/src/lib/query-freshness.ts b/web/src/lib/query-freshness.ts
new file mode 100644
index 0000000..ac9d03c
--- /dev/null
+++ b/web/src/lib/query-freshness.ts
@@ -0,0 +1,29 @@
+import type { ConnectError } from "@connectrpc/connect";
+
+/**
+ * The data a query fetched during *this* mount, or undefined.
+ *
+ * A form that seeds once from server data and then belongs to the author needs
+ * exactly this, and none of the three conditions is optional:
+ *
+ * - React Query returns a cached row synchronously when a query mounts and
+ * refetches behind it, so `data` alone can be a copy left by an earlier open
+ * of the same form, or by another page reading the same key.
+ * - A *failed* fetch also satisfies `isFetchedAfterMount` (it is
+ * `dataUpdateCount > initial || errorUpdateCount > initial`), and the error
+ * reducer keeps the cached data rather than clearing it - so "a fetch
+ * completed" is not "fresh data arrived".
+ * - `error` clears on the next success, so a form gated on this seeds as soon
+ * as a retry works.
+ *
+ * Pair it with `refetchOnMount: "always"` on the query itself: without that,
+ * whether a fetch happens at all depends on staleness, which is configured
+ * globally and elsewhere (`web/src/lib/query.ts`).
+ */
+export function freshData(query: {
+ data?: T;
+ error: ConnectError | null;
+ isFetchedAfterMount: boolean;
+}): T | undefined {
+ return query.isFetchedAfterMount && !query.error ? query.data : undefined;
+}
diff --git a/web/src/pages/AdminTemplatesPage.test.tsx b/web/src/pages/AdminTemplatesPage.test.tsx
index c286ed9..94c3416 100644
--- a/web/src/pages/AdminTemplatesPage.test.tsx
+++ b/web/src/pages/AdminTemplatesPage.test.tsx
@@ -332,3 +332,28 @@ it("refuses to save a template whose terminology row is incomplete", async () =>
expect(await screen.findByText(/must not be empty/i)).toBeInTheDocument();
expect(createMock).not.toHaveBeenCalled();
});
+
+it("shows why a duplicate term blocks saving instead of doing nothing", async () => {
+ // An array-level rule (the duplicate-term refine) is nested by
+ // @hookform/resolvers under `slots.terminology.root` rather than
+ // `slots.terminology`, because the path has registered children. A reader
+ // that only looks at `.message` finds nothing, so the form refuses to submit
+ // and says nothing about why - a Save button that does nothing at all.
+ createMock.mockClear();
+ renderPage();
+ await userEvent.click(screen.getByRole("button", { name: /new template/i }));
+ await userEvent.type(screen.getByLabelText(/^title$/i), "Vocabulary");
+ await userEvent.type(screen.getByLabelText(/^description$/i), "Our terms");
+
+ await userEvent.click(screen.getByRole("button", { name: /add term/i }));
+ await userEvent.type(screen.getAllByPlaceholderText("Term")[0], "Frame");
+ await userEvent.type(screen.getAllByPlaceholderText("Definition")[0], "One.");
+ await userEvent.click(screen.getByRole("button", { name: /add term/i }));
+ await userEvent.type(screen.getAllByPlaceholderText("Term")[1], "Frame");
+ await userEvent.type(screen.getAllByPlaceholderText("Definition")[1], "Two.");
+
+ await userEvent.click(screen.getByRole("button", { name: /^create$/i }));
+
+ expect(await screen.findByText(/duplicate term/i)).toBeInTheDocument();
+ expect(createMock).not.toHaveBeenCalled();
+});
diff --git a/web/src/pages/AdminTemplatesPage.tsx b/web/src/pages/AdminTemplatesPage.tsx
index a543016..621755f 100644
--- a/web/src/pages/AdminTemplatesPage.tsx
+++ b/web/src/pages/AdminTemplatesPage.tsx
@@ -12,6 +12,7 @@ import { Plus, Pencil, Trash2 } from "lucide-react";
import { SLOT_SECTIONS, type SlotSectionDef } from "@/lib/slot-sections";
import { parseFrameContent, serializeFramePrefill, type FrameDoc } from "@/lib/frame-yaml";
import { contentSlotsSchema } from "@/lib/authoring-schema";
+import { freshData } from "@/lib/query-freshness";
import { TerminologyEditor } from "@/components/form/TerminologyEditor";
import { ListEditor } from "@/components/form/ListEditor";
import { MarkdownField } from "@/components/form/MarkdownField";
@@ -126,10 +127,11 @@ function TemplateFormDialog({
const rowQ = useQuery(
FrameService.method.getFrameTemplate,
{ id: editingId },
- // Always refetch when the dialog opens, and seed only from that fetch (see
- // the effect below). Saving does not evict this row: invalidated data stays
- // in the cache and only active queries refetch, so without this the second
- // open of the same template would edit the copy from the first.
+ // Paired with freshData() on the seed below; see that helper for why
+ // neither half works alone. Saving does not evict this row - invalidated
+ // data stays in the cache and only active queries refetch - so without
+ // this pair the second open of a template would edit the copy from the
+ // first, and the next Save would write it back.
{ enabled: target.mode === "edit", refetchOnMount: "always" },
);
const createM = useMutation(FrameService.method.createFrameTemplate);
@@ -151,39 +153,16 @@ function TemplateFormDialog({
});
useEffect(() => {
- // Guarded on `ready`, not just `target.mode`: a query's `data` is not
- // guaranteed to be the same object across renders (a mock's factory can
- // rebuild it every call, and even the real client may on a background
- // refetch), so keying the effect on its identity alone would re-seed the
- // form - discarding whatever the admin had already typed - every time
- // that reference changes. Once ready, this effect is a no-op for the rest
- // of the dialog's lifetime.
+ // `ready` is the latch, and a boolean is the right shape here: this dialog
+ // is mounted per target and keyed on it, so its identity cannot change
+ // while it lives. (The authoring page's template comes from the URL and
+ // can, which is why that one latches on the id it seeded from.) Latching
+ // at all matters because a query's `data` is not a stable object across
+ // renders, so re-seeding on its identity would discard whatever the admin
+ // had already typed.
if (target.mode !== "edit" || ready) return;
- // Only a row this mount fetched may seed the form. React Query returns
- // whatever is already cached for the key synchronously - a copy left by an
- // earlier open of this dialog, or by the create flow's own template fetch -
- // and refetches behind it. Seeding from that copy and then latching `ready`
- // is what silently reverts a previous edit on the next Save.
- if (!rowQ.isFetchedAfterMount) return;
- // A fetch that FAILED satisfies the guard above: query-core counts data
- // updates and error updates alike (isFetchedAfterMount is
- // `dataUpdateCount > initial || errorUpdateCount > initial`), and its error
- // reducer keeps whatever data was already cached, flagging it invalidated
- // rather than clearing it. So without this, a forced refetch that fails
- // over an already-cached row would seed from that row - the stale content
- // this whole mechanism exists to refuse - and then latch, hiding the error
- // for the rest of the session.
- if (rowQ.error) return;
- const tmpl = rowQ.data?.template;
- if (!tmpl) {
- // A fetch that succeeded while carrying no row - the error case returned
- // above, so this claim is only ever made about a successful fetch. Not
- // something the RPC should produce, but if it does, `ready` would never
- // flip and the dialog would sit on its skeleton with nothing to explain
- // it. Scheduled outside the effect body, like the decode failure below.
- setTimeout(() => setLoadError("The registry returned no content for it."), 0);
- return;
- }
+ const tmpl = freshData(rowQ)?.template;
+ if (!tmpl) return;
try {
const doc = parseFrameContent(tmpl.prefill);
// This effect reacts to the row query resolving (an external system's
@@ -206,6 +185,7 @@ function TemplateFormDialog({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [target.mode, ready, rowQ.data, rowQ.error, rowQ.isFetchedAfterMount]);
+
const busy = createM.isPending || updateM.isPending;
const onSubmit = async (form: TemplateForm) => {
@@ -451,6 +431,9 @@ export function AdminTemplatesPage() {
{formTarget && (
!open && setFormTarget(null)}
onSaved={invalidate}
diff --git a/web/src/pages/FrameAuthoringPage.template.test.tsx b/web/src/pages/FrameAuthoringPage.template.test.tsx
index 14d25db..7758cf5 100644
--- a/web/src/pages/FrameAuthoringPage.template.test.tsx
+++ b/web/src/pages/FrameAuthoringPage.template.test.tsx
@@ -365,3 +365,29 @@ it("does not seed from a cached prefill when the fetch that ran on mount failed"
// Nothing was seeded, so this is still the dead end it looks like.
expect(screen.getByText(/could not be loaded/i)).toBeInTheDocument();
});
+
+it("treats a template whose stored content will not decode as a dead end", async () => {
+ // The fetch succeeds, so nothing is wrong with the id as far as the query is
+ // concerned - but nothing was seeded either. Calling that "seeded" would
+ // leave the author on a defaults-only form with the id still in the URL and
+ // still sent by every publish, whose required-slot check they never saw.
+ useQueryMock.mockImplementation((method: unknown) => {
+ if (method === FrameService.method.getFrameTemplate) {
+ return {
+ isLoading: false,
+ error: null,
+ isFetchedAfterMount: true,
+ data: { template: { ...orgTemplateWithPrefill, prefill: new TextEncoder().encode("slots: [not a mapping\n") } },
+ refetch: vi.fn(),
+ };
+ }
+ return templateList;
+ });
+ renderAt("/frames/new?template=01JORGTEMPLATE0000000000AB");
+
+ expect(await screen.findByText(/could not be loaded/i)).toBeInTheDocument();
+ expect(screen.getByText(/starting content could not be read/i)).toBeInTheDocument();
+ // The two ways out, same as a failed fetch.
+ expect(screen.getByRole("button", { name: /choose a different template/i })).toBeInTheDocument();
+ expect(screen.queryByLabelText(/frame name/i)).not.toBeInTheDocument();
+});
diff --git a/web/src/pages/FrameAuthoringPage.tsx b/web/src/pages/FrameAuthoringPage.tsx
index b982f92..bbd9e7f 100644
--- a/web/src/pages/FrameAuthoringPage.tsx
+++ b/web/src/pages/FrameAuthoringPage.tsx
@@ -17,6 +17,7 @@ import {
type TemplateRules,
} from "@/lib/templates";
import { mapPublishError } from "@/lib/publish-errors";
+import { freshData } from "@/lib/query-freshness";
import { type AuthoringForm, formToDoc, docToForm } from "@/components/form/form-model";
import { ExtendsEditor } from "@/components/form/ExtendsEditor";
import { ExcludesEditor } from "@/components/form/ExcludesEditor";
@@ -132,11 +133,8 @@ export function FrameAuthoringPage({ mode }: { mode: "create" | "edit" }) {
const chosen = useQuery(
FrameService.method.getFrameTemplate,
{ id: templateID },
- // `refetchOnMount: "always"` pairs with the isFetchedAfterMount guard on
- // the seed below - the same pair the template edit dialog uses. Neither
- // half works alone: without the option, the guard only happens to pass
- // because no global staleTime is set (web/src/lib/query.ts), and the day
- // one is added the seed would silently stop running.
+ // Paired with freshData() on the seed below; see that helper for why
+ // neither half works alone.
{ enabled: publishesTemplate, refetchOnMount: "always" },
);
const rules: TemplateRules = (chosen.data?.template?.fieldRules ?? {}) as TemplateRules;
@@ -167,32 +165,20 @@ export function FrameAuthoringPage({ mode }: { mode: "create" | "edit" }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, editQ.data?.version?.digest]);
+ // Keyed per template rather than per mount, because `?template=` is URL
+ // state and this page does not remount when it changes.
+ const [seededFor, setSeededFor] = useState(null);
+ // The template whose stored prefill would not decode. Keyed by id for the
+ // same reason, so picking another template does not inherit its failure.
+ const [unreadableFor, setUnreadableFor] = useState(null);
+
// Seeds the form once the chosen template arrives. This mirrors the edit
// path exactly: getFrameTemplate -> parseFrameContent -> docToForm are the
// same three steps that open an existing Frame, so there is no second
// parsing path to keep in step.
- // Which template seeded this form, not merely whether one did. Returning to
- // the picker without a remount is possible (the load-failure screen below
- // clears the id), and a boolean latch would then hold the first template's
- // content while every publish carried the second one's id - checking its
- // required slots against content the author never saw it ask for.
- const [seededFor, setSeededFor] = useState(null);
useEffect(() => {
if (mode !== "create" || templateID === "" || seededFor === templateID) return;
- // Only a prefill this mount fetched may seed the form. React Query returns
- // a cached row for the key synchronously and refetches behind it, so an
- // edit made in the admin page since it was cached would be invisible here.
- if (!chosen.isFetchedAfterMount) return;
- // A fetch that FAILED satisfies the guard above: query-core counts data
- // updates and error updates alike (isFetchedAfterMount is
- // `dataUpdateCount > initial || errorUpdateCount > initial`), and its error
- // reducer keeps whatever data was already cached, flagging it invalidated
- // rather than clearing it. So without this, a forced refetch that fails
- // over an already-cached row would seed from that row - the stale content
- // this whole mechanism exists to refuse - and then latch, hiding the error
- // for the rest of the session.
- if (chosen.error) return;
- const tmpl = chosen.data?.template;
+ const tmpl = freshData(chosen)?.template;
if (!tmpl) return;
try {
// The prefill is the canonical YAML subset the backend produced, so it
@@ -214,12 +200,16 @@ export function FrameAuthoringPage({ mode }: { mode: "create" | "edit" }) {
// apart from deriving state from other state, hence the disable.
// eslint-disable-next-line react-hooks/set-state-in-effect
setAdded(new Set([...fromRules, ...fromPrefill]));
+ // Latched inside the try: a prefill that did not decode seeded nothing,
+ // and calling it seeded would leave the author on a defaults-only form
+ // with the id still in the URL and still sent by every publish.
+ setSeededFor(templateID);
} catch {
- setTimeout(() => setFormError("This template's starting content could not be loaded."), 0);
+ // Scheduled outside the effect body to satisfy react-hooks/set-state-in-effect
+ setTimeout(() => setUnreadableFor(templateID), 0);
}
- setSeededFor(templateID);
- // seed once per template choice; re-running on every `rules` identity
- // change would fight the author's own edits after the initial seed
+ // `rules` is deliberately absent: it is derived from chosen.data, which is
+ // here, and listing it would re-run this effect on every identity change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chosen.data, chosen.error, chosen.isFetchedAfterMount, mode, templateID, seededFor]);
@@ -435,7 +425,8 @@ export function FrameAuthoringPage({ mode }: { mode: "create" | "edit" }) {
// A template that cannot be read is a dead end, not a blank form: the id
// stays in the URL and every publish carries it, so the server refuses each
// one for a template the author cannot see. Say so, and offer the two ways
- // out - retry, or go back and pick another.
+ // out - retry, or go back and pick another. Both failures land here: the
+ // fetch failing, and its stored content not decoding.
//
// Only while there is nothing to show. Once the prefill has seeded a form the
// author is typing into, this template is demonstrably readable and the id is
@@ -443,14 +434,15 @@ export function FrameAuthoringPage({ mode }: { mode: "create" | "edit" }) {
// refresh, a delete in another session), and replacing the form over one
// would throw away unsaved work to report a problem that no longer blocks
// anything.
- if (chosen.error && seededFor !== templateID) {
+ if ((chosen.error || unreadableFor === templateID) && seededFor !== templateID) {
return (
This template could not be loaded
- It may have been deleted, it may belong to another organization, or the registry
- could not be reached.
+ {chosen.error
+ ? "It may have been deleted, it may belong to another organization, or the registry could not be reached."
+ : "Its starting content could not be read."}
From 2636b02d1688d98a810029dc10d19210948f756f Mon Sep 17 00:00:00 2001
From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com>
Date: Wed, 9 Sep 2026 18:16:36 +0200
Subject: [PATCH 41/43] fix(web): withhold a form until its seed lands, and
drop three false rationales
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`refetchOnMount: "always"` guarantees a template's prefill arrives a round
trip after the first render, and `reset` replaces form state wholesale - so
the create form was editable for exactly as long as it took to discard
anything typed into it. It now withholds the form the way the edit dialog
already did. The test harnesses that render "past the picker" say what that
means in the app: a template that was chosen and fetched.
Three comments claimed constraints the code does not have, which is the same
defect the previous round corrected in AGENTS.md, one level down:
- Two `setTimeout(…, 0)` wrappers cited react-hooks/set-state-in-effect. The
rule reports at most one violation per effect and an earlier disable had
already spent it in both, so neither deferral was doing anything; verified
by unwrapping them and running the linter. AGENTS.md records the trap,
since the linter's silence is what misled.
- The seed effect spelled the "does this template matter" condition itself
while the fetch and every publish read `publishTemplateID`, which the
surrounding comment claims cannot happen.
- "Try again" was offered for a prefill that will not decode, where the
stored bytes are the input and a retry redraws the same screen.
Both effects now depend on the row they use rather than on the three query
fields `freshData` reads, so adding a condition to that helper cannot leave
a hand-maintained dependency array silently under-specified.
---
AGENTS.md | 15 ++++--
web/src/pages/AdminTemplatesPage.tsx | 41 ++++++++-------
.../pages/FrameAuthoringPage.create.test.tsx | 21 +++++++-
.../FrameAuthoringPage.markdown.test.tsx | 21 +++++++-
.../FrameAuthoringPage.template.test.tsx | 16 ++++++
web/src/pages/FrameAuthoringPage.tsx | 52 ++++++++++++++-----
6 files changed, 128 insertions(+), 38 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index f2ad50f..d8cc868 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -101,9 +101,10 @@ store.Repository**.
in `ParsePrefill`: that decoder is also the read path (`rowToTemplate` runs every stored row
through it), so a rule enforced there would make an already-stored row unreadable rather than
merely unpublishable. Starters arrive through two doors - the write RPCs and `loadBuiltins` at
- package init. Built-ins carry rules and notes but no content: `builtinFile` has no `prefill`
- key, and `TestBuiltinFileCarriesNoPrefill` fails if one is added without running the content
- check in `loadBuiltins`, so that door cannot open unguarded.
+ package init. Built-ins carry rules and notes but no content at all: `builtinFile` has no
+ `prefill` key, so there is nothing for a content rule to check there.
+ `TestBuiltinFileCarriesNoPrefill` is a tripwire over that premise - add such a field and it
+ fails, telling you to run `contentErrors` in `loadBuiltins` and to replace the tripwire.
- `framemd.go` - the Frame Spec v0.2 `.frame.md` codec (YAML frontmatter plus one `##` section per
slot). Round-trip fidelity matters: `examples/*.yaml` and `examples/*.frame.md` are checked-in
conformance fixtures asserted by `examples_test.go`.
@@ -155,6 +156,14 @@ store.Repository**.
per target, so a boolean is its identity, while the authoring page's template comes from
`?template=` and can change while the page stays mounted, so it latches on the id it seeded
from.
+ - A form seeded from a query does not render editable before the seed lands. Both template
+ surfaces withhold it (a skeleton, or the dialog's), because `reset` replaces form state
+ wholesale and `refetchOnMount: "always"` guarantees the seed arrives a round trip after the
+ first render - so anything typed in between would vanish.
+ - `react-hooks/set-state-in-effect` reports at most one violation per effect, so a second
+ `setState` in the same effect can look clean while being the same construct. Do not read the
+ linter's silence as a constraint, and do not add a `setTimeout` claiming to satisfy a rule
+ that never fired - an unused `eslint-disable` is itself reported.
- Two schemas cover the same slot keys on purpose. `frame-yaml.ts` decodes whatever is stored
(lenient, because rows predate rules); `contentSlotsSchema` in `authoring-schema.ts` is what a
human may submit, and every form that edits slot content resolves against it. The backend
diff --git a/web/src/pages/AdminTemplatesPage.tsx b/web/src/pages/AdminTemplatesPage.tsx
index 621755f..eb558cc 100644
--- a/web/src/pages/AdminTemplatesPage.tsx
+++ b/web/src/pages/AdminTemplatesPage.tsx
@@ -152,39 +152,38 @@ function TemplateFormDialog({
defaultValues: emptyTemplateForm(),
});
+ // Read here rather than inside the effect, so the effect depends on the value
+ // it uses instead of on the three query fields freshData happens to read.
+ const freshRow = freshData(rowQ)?.template;
useEffect(() => {
- // `ready` is the latch, and a boolean is the right shape here: this dialog
- // is mounted per target and keyed on it, so its identity cannot change
- // while it lives. (The authoring page's template comes from the URL and
- // can, which is why that one latches on the id it seeded from.) Latching
- // at all matters because a query's `data` is not a stable object across
- // renders, so re-seeding on its identity would discard whatever the admin
- // had already typed.
+ // A boolean latch, because this dialog is keyed and mounted per target, so
+ // its identity cannot change while it lives. Latching at all matters
+ // because a query's `data` is not a stable object across renders, and
+ // re-seeding on its identity would discard what the admin had typed.
if (target.mode !== "edit" || ready) return;
- const tmpl = freshData(rowQ)?.template;
- if (!tmpl) return;
+ if (!freshRow) return;
try {
- const doc = parseFrameContent(tmpl.prefill);
+ const doc = parseFrameContent(freshRow.prefill);
// This effect reacts to the row query resolving (an external system's
// data arriving), which is the effect rule's own sanctioned use of
// setState-in-effect; the heuristic cannot tell that apart from deriving
// state from other state, hence the disable below.
+ // Reacting to an external system's data arriving, which is the effect
+ // rule's own sanctioned use of setState-in-effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setCarriedExtends(doc.extends);
methods.reset({
- title: tmpl.title,
- description: tmpl.description,
+ title: freshRow.title,
+ description: freshRow.description,
slots: doc.slots,
- rules: tmpl.fieldRules as TemplateForm["rules"],
+ rules: freshRow.fieldRules as TemplateForm["rules"],
});
setReady(true);
} catch {
- // Schedule outside the effect body to satisfy react-hooks/set-state-in-effect
- setTimeout(() => setLoadError("Its saved content could not be read."), 0);
+ setLoadError("Its saved content could not be read.");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [target.mode, ready, rowQ.data, rowQ.error, rowQ.isFetchedAfterMount]);
-
+ }, [target.mode, ready, freshRow]);
const busy = createM.isPending || updateM.isPending;
@@ -228,6 +227,8 @@ function TemplateFormDialog({
rowQ.error || loadError ? (
This template could not be loaded
+ {/* No "another organization" clause, unlike the authoring page's:
+ this dialog only ever opens rows from the admin's own org. */}
{loadError ?? "It may have been deleted, or the registry could not be reached."}
@@ -431,8 +432,10 @@ export function AdminTemplatesPage() {
{formTarget && (
!open && setFormTarget(null)}
diff --git a/web/src/pages/FrameAuthoringPage.create.test.tsx b/web/src/pages/FrameAuthoringPage.create.test.tsx
index 7e13050..65c21f3 100644
--- a/web/src/pages/FrameAuthoringPage.create.test.tsx
+++ b/web/src/pages/FrameAuthoringPage.create.test.tsx
@@ -18,7 +18,26 @@ vi.mock("react-router", async (orig) => ({
// second onSuccess/onError argument.
const mutateMock = vi.fn();
vi.mock("@connectrpc/connect-query", () => ({
- useQuery: () => ({ data: { org: { slug: "openteams" } }, isLoading: false, error: null }),
+ useQuery: () => ({
+ // Rendering past the picker means a template was chosen AND fetched: the
+ // page withholds the form until the seed lands, because reset() would
+ // otherwise discard anything typed in the meantime. One object serves both
+ // readers - each looks at its own key.
+ data: {
+ org: { slug: "openteams" },
+ template: {
+ id: "builtin:blank",
+ title: "Blank",
+ description: "Empty.",
+ builtin: true,
+ prefill: new TextEncoder().encode("slots: {}\n"),
+ fieldRules: {},
+ },
+ },
+ isLoading: false,
+ error: null,
+ isFetchedAfterMount: true,
+ }),
useMutation: () => ({ mutateAsync: mutateMock, isPending: false, isSuccess: false }),
createConnectQueryKey: () => ["k"],
}));
diff --git a/web/src/pages/FrameAuthoringPage.markdown.test.tsx b/web/src/pages/FrameAuthoringPage.markdown.test.tsx
index 997315c..215a267 100644
--- a/web/src/pages/FrameAuthoringPage.markdown.test.tsx
+++ b/web/src/pages/FrameAuthoringPage.markdown.test.tsx
@@ -20,7 +20,26 @@ const { convertMock, publishMock } = vi.hoisted(() => ({
}));
vi.mock("@connectrpc/connect-query", () => ({
- useQuery: () => ({ data: { org: { slug: "openteams" } }, isLoading: false, error: null }),
+ useQuery: () => ({
+ // Rendering past the picker means a template was chosen AND fetched: the
+ // page withholds the form until the seed lands, because reset() would
+ // otherwise discard anything typed in the meantime. One object serves both
+ // readers - each looks at its own key.
+ data: {
+ org: { slug: "openteams" },
+ template: {
+ id: "builtin:blank",
+ title: "Blank",
+ description: "Empty.",
+ builtin: true,
+ prefill: new TextEncoder().encode("slots: {}\n"),
+ fieldRules: {},
+ },
+ },
+ isLoading: false,
+ error: null,
+ isFetchedAfterMount: true,
+ }),
useMutation: (method: { name?: string }) => ({
mutate: method?.name === "ConvertFrame" ? convertMock : publishMock,
isPending: false,
diff --git a/web/src/pages/FrameAuthoringPage.template.test.tsx b/web/src/pages/FrameAuthoringPage.template.test.tsx
index 7758cf5..a2a2caa 100644
--- a/web/src/pages/FrameAuthoringPage.template.test.tsx
+++ b/web/src/pages/FrameAuthoringPage.template.test.tsx
@@ -27,6 +27,17 @@ const templateList = {
error: null,
isFetchedAfterMount: true,
data: {
+ // A chosen template is also a fetched one - the page withholds the form
+ // until the seed lands - so this default carries the row alongside the
+ // list. Each reader looks at its own key.
+ template: {
+ id: "builtin:blank",
+ title: "Blank",
+ description: "Empty.",
+ builtin: true,
+ prefill: new TextEncoder().encode("slots: {}\n"),
+ fieldRules: {},
+ },
canManage: false,
templates: [
{ id: "builtin:blank", title: "Blank", description: "Empty.", builtin: true },
@@ -321,6 +332,11 @@ it("seeds the create form only from a prefill fetched after this mount", async (
const { rerender } = renderAt("/frames/new?template=01JORGTEMPLATE0000000000AB");
expect(screen.queryByDisplayValue("Stale")).not.toBeInTheDocument();
+ // And no editable form yet either: `reset` replaces form state wholesale, so
+ // anything typed in this window - which refetchOnMount: "always" guarantees
+ // is at least one round trip - would be silently discarded when the seed
+ // lands.
+ expect(screen.queryByLabelText(/frame name/i)).not.toBeInTheDocument();
fetched = true;
rerender(
diff --git a/web/src/pages/FrameAuthoringPage.tsx b/web/src/pages/FrameAuthoringPage.tsx
index bbd9e7f..2cfdea1 100644
--- a/web/src/pages/FrameAuthoringPage.tsx
+++ b/web/src/pages/FrameAuthoringPage.tsx
@@ -30,6 +30,7 @@ import { AddSectionMenu } from "@/components/document/AddSectionMenu";
import { PublishDialog } from "@/components/document/PublishDialog";
import { TemplatePicker } from "@/components/frame/TemplatePicker";
import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import {
DropdownMenu,
@@ -165,8 +166,8 @@ export function FrameAuthoringPage({ mode }: { mode: "create" | "edit" }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, editQ.data?.version?.digest]);
- // Keyed per template rather than per mount, because `?template=` is URL
- // state and this page does not remount when it changes.
+ // Keyed per template, because `?template=` is URL state and this page does
+ // not remount when it changes.
const [seededFor, setSeededFor] = useState(null);
// The template whose stored prefill would not decode. Keyed by id for the
// same reason, so picking another template does not inherit its failure.
@@ -176,14 +177,18 @@ export function FrameAuthoringPage({ mode }: { mode: "create" | "edit" }) {
// path exactly: getFrameTemplate -> parseFrameContent -> docToForm are the
// same three steps that open an existing Frame, so there is no second
// parsing path to keep in step.
+ // Read here rather than inside the effect, so the effect depends on the value
+ // it uses instead of on the three query fields freshData happens to read.
+ const freshTemplate = freshData(chosen)?.template;
useEffect(() => {
- if (mode !== "create" || templateID === "" || seededFor === templateID) return;
- const tmpl = freshData(chosen)?.template;
- if (!tmpl) return;
+ // publishesTemplate, not a fourth spelling of the same condition: the
+ // fetch, the seed and every publish read one rule.
+ if (!publishesTemplate || seededFor === templateID) return;
+ if (!freshTemplate) return;
try {
// The prefill is the canonical YAML subset the backend produced, so it
// goes through the same parse the edit flow uses.
- const doc = parseFrameContent(tmpl.prefill);
+ const doc = parseFrameContent(freshTemplate.prefill);
methods.reset(docToForm({ ...doc, version: "1.0.0" }, ""));
// Sections the template asks for are on the page from the start rather
// than behind "+ Add section": the point of a template is that the
@@ -205,13 +210,13 @@ export function FrameAuthoringPage({ mode }: { mode: "create" | "edit" }) {
// with the id still in the URL and still sent by every publish.
setSeededFor(templateID);
} catch {
- // Scheduled outside the effect body to satisfy react-hooks/set-state-in-effect
- setTimeout(() => setUnreadableFor(templateID), 0);
+ setUnreadableFor(templateID);
}
- // `rules` is deliberately absent: it is derived from chosen.data, which is
- // here, and listing it would re-run this effect on every identity change.
+ // `rules` is deliberately absent: it comes from the same row as
+ // freshTemplate, and listing it would re-run this effect on every identity
+ // change.
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [chosen.data, chosen.error, chosen.isFetchedAfterMount, mode, templateID, seededFor]);
+ }, [freshTemplate, publishesTemplate, templateID, seededFor]);
const slots = useWatch({ control: methods.control, name: "slots" }) as
| AuthoringForm["slots"]
@@ -446,9 +451,14 @@ export function FrameAuthoringPage({ mode }: { mode: "create" | "edit" }) {
- void chosen.refetch()}>
- Try again
-
+ {/* Only for a failed fetch. A prefill that will not decode fails the
+ same way every time - the stored bytes are the input - so a retry
+ there would just redraw this screen. */}
+ {chosen.error && (
+ void chosen.refetch()}>
+ Try again
+
+ )}
+
+
+
+ );
+ }
+
return (
e.preventDefault()} className="mx-auto max-w-3xl space-y-6">
From 7aa310c8c6dd77bbdbfa73056a676c21a1032658 Mon Sep 17 00:00:00 2001
From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com>
Date: Wed, 9 Sep 2026 18:29:07 +0200
Subject: [PATCH 42/43] test: pin the two invariants the last round left
resting on reading
The web dialog treats "a row I do not have" as "still fetching", so a
GetFrameTemplate success carrying no template would strand it on a loading
skeleton with nothing to report. Deleting its defensive branch was right -
the RPC cannot produce that - but nothing said so or held it there. Now the
method documents it and a test asserts it across every id shape a caller can
send, including the ones that error.
The retry offered on a failed template load was also unpinned: removing the
button entirely, or offering it for a prefill that will not decode, both left
the suite green. Its presence and its absence are now asserted where each
belongs.
---
backend/internal/frames/templates_service.go | 5 +++
.../internal/frames/templates_service_test.go | 34 +++++++++++++++++++
.../FrameAuthoringPage.template.test.tsx | 6 +++-
3 files changed, 44 insertions(+), 1 deletion(-)
diff --git a/backend/internal/frames/templates_service.go b/backend/internal/frames/templates_service.go
index 20857dc..6d529c2 100644
--- a/backend/internal/frames/templates_service.go
+++ b/backend/internal/frames/templates_service.go
@@ -147,6 +147,11 @@ func (s *Service) ListFrameTemplates(ctx context.Context, _ *connect.Request[fra
}), nil
}
+// GetFrameTemplate returns one template the caller may use. A response is
+// never successful and empty: either an error, or a Template. The web app's
+// edit dialog relies on that - it treats "no row" as still loading, so a
+// success carrying nothing would leave it on a loading skeleton forever rather
+// than reporting anything. TestGetFrameTemplateNeverSucceedsEmpty pins it.
func (s *Service) GetFrameTemplate(ctx context.Context, req *connect.Request[framesv1.GetFrameTemplateRequest]) (*connect.Response[framesv1.GetFrameTemplateResponse], error) {
caller, err := s.resolveCaller(ctx)
if err != nil {
diff --git a/backend/internal/frames/templates_service_test.go b/backend/internal/frames/templates_service_test.go
index 6ea78ec..d208ac5 100644
--- a/backend/internal/frames/templates_service_test.go
+++ b/backend/internal/frames/templates_service_test.go
@@ -582,3 +582,37 @@ func TestTemplateWritesRefusePrefillContentThatCannotBePublished(t *testing.T) {
})
}
}
+
+// A success carrying no template would strand the web app's edit dialog on its
+// loading skeleton: "no row" is how that dialog spells "still fetching", so it
+// has nothing to report. Asserted across every id shape a caller can send,
+// because the invariant has to hold for the whole method, not just the happy
+// path.
+func TestGetFrameTemplateNeverSucceedsEmpty(t *testing.T) {
+ tests := []struct {
+ name string
+ id string
+ }{
+ {name: "a built-in", id: BlankTemplateID},
+ {name: "the org's own row", id: "t1"},
+ {name: "another org's row", id: "t2"},
+ {name: "an id that does not exist", id: "01JNOPE0000000000000000000"},
+ {name: "an empty id", id: ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ f, ctx := newTemplateFixture(t, "admin")
+ seedOrgTemplate(t, f.repo, "t1", "org-a", "Ours", nil)
+ seedOrgTemplate(t, f.repo, "t2", "org-b", "Not Yours", nil)
+
+ resp, err := f.svc.GetFrameTemplate(ctx, connect.NewRequest(&framesv1.GetFrameTemplateRequest{Id: tt.id}))
+ if err != nil {
+ return // an error is a fine answer; an empty success is not
+ }
+ if resp.Msg.Template == nil {
+ t.Fatal("GetFrameTemplate returned a success with no template")
+ }
+ })
+ }
+}
diff --git a/web/src/pages/FrameAuthoringPage.template.test.tsx b/web/src/pages/FrameAuthoringPage.template.test.tsx
index a2a2caa..22c70fd 100644
--- a/web/src/pages/FrameAuthoringPage.template.test.tsx
+++ b/web/src/pages/FrameAuthoringPage.template.test.tsx
@@ -269,6 +269,8 @@ it("offers a way out when the chosen template cannot be read", async () => {
expect(screen.queryByLabelText(/frame name/i)).not.toBeInTheDocument();
expect(screen.getByText(/could not be loaded/i)).toBeInTheDocument();
+ // A fetch can succeed on a second attempt, so the retry belongs here.
+ expect(screen.getByRole("button", { name: /try again/i })).toBeInTheDocument();
// Back to the picker, with the bad id cleared from the URL rather than left
// for the next publish to carry.
@@ -403,7 +405,9 @@ it("treats a template whose stored content will not decode as a dead end", async
expect(await screen.findByText(/could not be loaded/i)).toBeInTheDocument();
expect(screen.getByText(/starting content could not be read/i)).toBeInTheDocument();
- // The two ways out, same as a failed fetch.
expect(screen.getByRole("button", { name: /choose a different template/i })).toBeInTheDocument();
+ // But no retry: the stored bytes are the input, so a refetch returns the
+ // same content and redraws this same screen.
+ expect(screen.queryByRole("button", { name: /try again/i })).not.toBeInTheDocument();
expect(screen.queryByLabelText(/frame name/i)).not.toBeInTheDocument();
});
From ed03e071e7f4c75f9137a98608294892cfd41eeb Mon Sep 17 00:00:00 2001
From: Chuck McAndrew <6248903+dcmcand@users.noreply.github.com>
Date: Thu, 10 Sep 2026 08:12:10 +0200
Subject: [PATCH 43/43] test: assert the non-empty-success invariant where it
is already exercised
The five-row table added for it asserted nothing on three of its rows: an id
that errors satisfies "if success, then non-nil" trivially, and the error
codes for those three shapes are already pinned exactly by
TestGetFrameTemplate. Under a mutation that empties only the success path,
those rows passed regardless.
Folded into that test's success cases instead, where it fails with a message
rather than the nil dereference the existing field reads would have produced,
and the doc comment now claims only what is checked.
---
backend/internal/frames/templates_service.go | 3 +-
.../internal/frames/templates_service_test.go | 41 ++++---------------
2 files changed, 9 insertions(+), 35 deletions(-)
diff --git a/backend/internal/frames/templates_service.go b/backend/internal/frames/templates_service.go
index 6d529c2..2831481 100644
--- a/backend/internal/frames/templates_service.go
+++ b/backend/internal/frames/templates_service.go
@@ -151,7 +151,8 @@ func (s *Service) ListFrameTemplates(ctx context.Context, _ *connect.Request[fra
// never successful and empty: either an error, or a Template. The web app's
// edit dialog relies on that - it treats "no row" as still loading, so a
// success carrying nothing would leave it on a loading skeleton forever rather
-// than reporting anything. TestGetFrameTemplateNeverSucceedsEmpty pins it.
+// than reporting anything. TestGetFrameTemplate pins it on every id that
+// succeeds.
func (s *Service) GetFrameTemplate(ctx context.Context, req *connect.Request[framesv1.GetFrameTemplateRequest]) (*connect.Response[framesv1.GetFrameTemplateResponse], error) {
caller, err := s.resolveCaller(ctx)
if err != nil {
diff --git a/backend/internal/frames/templates_service_test.go b/backend/internal/frames/templates_service_test.go
index d208ac5..df13e8d 100644
--- a/backend/internal/frames/templates_service_test.go
+++ b/backend/internal/frames/templates_service_test.go
@@ -163,6 +163,13 @@ func TestGetFrameTemplate(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
got := resp.Msg.Template
+ // A success must carry a template. The web edit dialog reads "no
+ // row" as "still fetching", so an empty success would strand it on
+ // its loading skeleton with nothing to report - which is why that
+ // dialog has no defensive branch for it.
+ if got == nil {
+ t.Fatal("success carried no template")
+ }
if got.Id != tt.id {
t.Errorf("id = %q, want %q", got.Id, tt.id)
}
@@ -582,37 +589,3 @@ func TestTemplateWritesRefusePrefillContentThatCannotBePublished(t *testing.T) {
})
}
}
-
-// A success carrying no template would strand the web app's edit dialog on its
-// loading skeleton: "no row" is how that dialog spells "still fetching", so it
-// has nothing to report. Asserted across every id shape a caller can send,
-// because the invariant has to hold for the whole method, not just the happy
-// path.
-func TestGetFrameTemplateNeverSucceedsEmpty(t *testing.T) {
- tests := []struct {
- name string
- id string
- }{
- {name: "a built-in", id: BlankTemplateID},
- {name: "the org's own row", id: "t1"},
- {name: "another org's row", id: "t2"},
- {name: "an id that does not exist", id: "01JNOPE0000000000000000000"},
- {name: "an empty id", id: ""},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- f, ctx := newTemplateFixture(t, "admin")
- seedOrgTemplate(t, f.repo, "t1", "org-a", "Ours", nil)
- seedOrgTemplate(t, f.repo, "t2", "org-b", "Not Yours", nil)
-
- resp, err := f.svc.GetFrameTemplate(ctx, connect.NewRequest(&framesv1.GetFrameTemplateRequest{Id: tt.id}))
- if err != nil {
- return // an error is a fine answer; an empty success is not
- }
- if resp.Msg.Template == nil {
- t.Fatal("GetFrameTemplate returned a success with no template")
- }
- })
- }
-}