From a1c2575c4653844807bae3852962ffc2b01b2003 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Thu, 13 Aug 2026 16:08:19 +0530 Subject: [PATCH 01/10] refactor(metaschema): move default schemas into core/metaschema --- core/metaschema/defaults.go | 39 +++++++++++++++++++ core/metaschema/defaults_test.go | 27 +++++++++++++ .../metaschema}/metaschemas/group.json | 0 .../metaschema}/metaschemas/org.json | 0 .../metaschema}/metaschemas/prospect.json | 0 .../metaschema}/metaschemas/role.json | 0 .../metaschema}/metaschemas/user.json | 0 .../store/postgres/metaschema_repository.go | 34 +--------------- 8 files changed, 67 insertions(+), 33 deletions(-) create mode 100644 core/metaschema/defaults.go create mode 100644 core/metaschema/defaults_test.go rename {internal/store/postgres => core/metaschema}/metaschemas/group.json (100%) rename {internal/store/postgres => core/metaschema}/metaschemas/org.json (100%) rename {internal/store/postgres => core/metaschema}/metaschemas/prospect.json (100%) rename {internal/store/postgres => core/metaschema}/metaschemas/role.json (100%) rename {internal/store/postgres => core/metaschema}/metaschemas/user.json (100%) diff --git a/core/metaschema/defaults.go b/core/metaschema/defaults.go new file mode 100644 index 0000000000..e189d53c8e --- /dev/null +++ b/core/metaschema/defaults.go @@ -0,0 +1,39 @@ +package metaschema + +import _ "embed" + +// Built-in metaschema names. These are the schemas the server validates entity +// metadata against, and the set the MetaSchema reconcile kind manages. +const ( + NameUser = "user" + NameGroup = "group" + NameOrg = "organization" + NameRole = "role" + NameProspect = "prospect" +) + +//go:embed metaschemas/user.json +var defaultUser []byte + +//go:embed metaschemas/group.json +var defaultGroup []byte + +//go:embed metaschemas/org.json +var defaultOrg []byte + +//go:embed metaschemas/role.json +var defaultRole []byte + +//go:embed metaschemas/prospect.json +var defaultProspect []byte + +// Defaults maps each built-in metaschema name to its shipped JSON schema. It is +// the one source for both the server seeding (MigrateDefaults) and the MetaSchema +// reconcile kind, so adding a metaschema for a new resource is a single edit here. +var Defaults = map[string]string{ + NameUser: string(defaultUser), + NameGroup: string(defaultGroup), + NameOrg: string(defaultOrg), + NameRole: string(defaultRole), + NameProspect: string(defaultProspect), +} diff --git a/core/metaschema/defaults_test.go b/core/metaschema/defaults_test.go new file mode 100644 index 0000000000..c2da79f6ec --- /dev/null +++ b/core/metaschema/defaults_test.go @@ -0,0 +1,27 @@ +package metaschema + +import ( + "encoding/json" + "testing" +) + +func TestDefaults(t *testing.T) { + want := []string{NameUser, NameGroup, NameOrg, NameRole, NameProspect} + if len(Defaults) != len(want) { + t.Fatalf("Defaults has %d entries, want %d", len(Defaults), len(want)) + } + for _, name := range want { + schema, ok := Defaults[name] + if !ok { + t.Errorf("Defaults is missing %q", name) + continue + } + if schema == "" { + t.Errorf("Defaults[%q] is empty", name) + } + var v any + if err := json.Unmarshal([]byte(schema), &v); err != nil { + t.Errorf("Defaults[%q] is not valid JSON: %v", name, err) + } + } +} diff --git a/internal/store/postgres/metaschemas/group.json b/core/metaschema/metaschemas/group.json similarity index 100% rename from internal/store/postgres/metaschemas/group.json rename to core/metaschema/metaschemas/group.json diff --git a/internal/store/postgres/metaschemas/org.json b/core/metaschema/metaschemas/org.json similarity index 100% rename from internal/store/postgres/metaschemas/org.json rename to core/metaschema/metaschemas/org.json diff --git a/internal/store/postgres/metaschemas/prospect.json b/core/metaschema/metaschemas/prospect.json similarity index 100% rename from internal/store/postgres/metaschemas/prospect.json rename to core/metaschema/metaschemas/prospect.json diff --git a/internal/store/postgres/metaschemas/role.json b/core/metaschema/metaschemas/role.json similarity index 100% rename from internal/store/postgres/metaschemas/role.json rename to core/metaschema/metaschemas/role.json diff --git a/internal/store/postgres/metaschemas/user.json b/core/metaschema/metaschemas/user.json similarity index 100% rename from internal/store/postgres/metaschemas/user.json rename to core/metaschema/metaschemas/user.json diff --git a/internal/store/postgres/metaschema_repository.go b/internal/store/postgres/metaschema_repository.go index bc2292cadf..dbf850f476 100644 --- a/internal/store/postgres/metaschema_repository.go +++ b/internal/store/postgres/metaschema_repository.go @@ -3,7 +3,6 @@ package postgres import ( "context" "database/sql" - _ "embed" "fmt" "strings" @@ -16,37 +15,6 @@ import ( "github.com/raystack/frontier/pkg/db" ) -var ( - userMetaSchemaName = "user" - groupMetaSchemaName = "group" - orgMetaSchemaName = "organization" - rolesMetaSchemaName = "role" - prospectMetaSchemaName = "prospect" -) - -//go:embed metaschemas/user.json -var defaultUser []byte - -//go:embed metaschemas/group.json -var defaultGroup []byte - -//go:embed metaschemas/org.json -var defaultOrg []byte - -//go:embed metaschemas/role.json -var defaultRole []byte - -//go:embed metaschemas/prospect.json -var defaultProspect []byte - -var defaultMetaSchemas = map[string]string{ - userMetaSchemaName: string(defaultUser), - groupMetaSchemaName: string(defaultGroup), - orgMetaSchemaName: string(defaultOrg), - rolesMetaSchemaName: string(defaultRole), - prospectMetaSchemaName: string(defaultProspect), -} - type MetaSchemaRepository struct { log *slog.Logger dbc *db.Client @@ -206,7 +174,7 @@ func (m MetaSchemaRepository) Update(ctx context.Context, id string, mschema met // add default schemas to db once during database migration func (m MetaSchemaRepository) MigrateDefaults(ctx context.Context) error { - for name, schema := range defaultMetaSchemas { + for name, schema := range metaschema.Defaults { if _, err := m.Create(ctx, metaschema.MetaSchema{ Name: name, Schema: schema, From 8c87c1ea552cf0f83d145637ea0fc8ad7f00989f Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Thu, 13 Aug 2026 16:14:30 +0530 Subject: [PATCH 02/10] feat(reconcile): add metaschema diff and spec for the MetaSchema kind --- internal/reconcile/metaschema.go | 171 ++++++++++++++++++++++++++ internal/reconcile/metaschema_test.go | 109 ++++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 internal/reconcile/metaschema.go create mode 100644 internal/reconcile/metaschema_test.go diff --git a/internal/reconcile/metaschema.go b/internal/reconcile/metaschema.go new file mode 100644 index 0000000000..aaa4deca4c --- /dev/null +++ b/internal/reconcile/metaschema.go @@ -0,0 +1,171 @@ +package reconcile + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +// KindMetaSchema is the desired-state document kind for entity metaschemas. +const KindMetaSchema = "MetaSchema" + +// MetaSchemaSpec is one desired metaschema. Name is a built-in metaschema name +// the server knows; Schema is the JSON schema as a string. +type MetaSchemaSpec struct { + Name string `yaml:"name"` + Schema string `yaml:"schema"` +} + +// currentMetaSchema is a metaschema as it exists on the server. +type currentMetaSchema struct { + ID string + Name string + Schema string +} + +// metaSchemaOp is a single planned change. schema is the JSON to write; id is the +// server id for an update, empty when the metaschema must be created. fromDefault +// marks a reset, so the plan can say so. +type metaSchemaOp struct { + name string + id string + schema string + fromDefault bool +} + +func (o metaSchemaOp) String() string { + switch { + case o.id == "": + return fmt.Sprintf("create metaschema %s", o.name) + case o.fromDefault: + return fmt.Sprintf("reset metaschema %s to default", o.name) + default: + return fmt.Sprintf("set metaschema %s", o.name) + } +} + +// canonicalJSON returns a stable form of a JSON document, so two schemas that +// differ only in whitespace or key order compare equal. It keeps the export +// round-trip stable and stops formatting from making a false diff. +func canonicalJSON(s string) (string, error) { + var v any + if err := json.Unmarshal([]byte(s), &v); err != nil { + return "", err + } + b, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(b), nil +} + +// validateMetaSchemaSpecs checks every entry without touching the server: the +// name is a known built-in, no name repeats, and the schema is non-empty valid +// JSON. defaults is the managed set and the source of known names. +func validateMetaSchemaSpecs(specs []MetaSchemaSpec, defaults map[string]string) error { + seen := map[string]struct{}{} + for _, s := range specs { + name := strings.TrimSpace(s.Name) + if name == "" { + return fmt.Errorf("metaschema name is required") + } + if _, ok := defaults[name]; !ok { + return fmt.Errorf("unknown metaschema %q", name) + } + if _, dup := seen[name]; dup { + return fmt.Errorf("metaschema %q is listed more than once", name) + } + seen[name] = struct{}{} + if strings.TrimSpace(s.Schema) == "" { + return fmt.Errorf("metaschema %q: schema is required", name) + } + if _, err := canonicalJSON(s.Schema); err != nil { + return fmt.Errorf("metaschema %q: schema is not valid JSON: %w", name, err) + } + } + return nil +} + +// diffMetaSchemas returns the ops that make the server's built-in metaschemas +// match the desired spec. The file is the full desired state: a built-in the file +// lists is set to its schema, and a built-in the file leaves out is reset to its +// shipped default. defaults is the managed set: its keys are the built-ins, its +// values the reset targets. +func diffMetaSchemas(desired []MetaSchemaSpec, current []currentMetaSchema, defaults map[string]string) ([]metaSchemaOp, error) { + desiredByName := make(map[string]string, len(desired)) + for _, s := range desired { + desiredByName[strings.TrimSpace(s.Name)] = s.Schema + } + currentByName := make(map[string]currentMetaSchema, len(current)) + for _, c := range current { + currentByName[c.Name] = c + } + + names := make([]string, 0, len(defaults)) + for name := range defaults { + names = append(names, name) + } + sort.Strings(names) + + var ops []metaSchemaOp + for _, name := range names { + want, inFile := desiredByName[name] + if !inFile { + want = defaults[name] + } + wantCanon, err := canonicalJSON(want) + if err != nil { + return nil, fmt.Errorf("metaschema %q: schema is not valid JSON: %w", name, err) + } + + cur, exists := currentByName[name] + if exists { + if curCanon, err := canonicalJSON(cur.Schema); err == nil && curCanon == wantCanon { + continue + } + } + ops = append(ops, metaSchemaOp{ + name: name, + id: cur.ID, // empty when the metaschema is not on the server + schema: want, + fromDefault: !inFile, + }) + } + return ops, nil +} + +// exportMetaSchemas returns the built-ins whose current schema differs from the +// default, sorted by name, as a desired-state spec. A built-in at its default is +// omitted, so reconciling an export plans no changes. +func exportMetaSchemas(current []currentMetaSchema, defaults map[string]string) ([]MetaSchemaSpec, error) { + byName := make(map[string]currentMetaSchema, len(current)) + for _, c := range current { + byName[c.Name] = c + } + names := make([]string, 0, len(defaults)) + for name := range defaults { + names = append(names, name) + } + sort.Strings(names) + + var specs []MetaSchemaSpec + for _, name := range names { + cur, exists := byName[name] + if !exists { + continue + } + curCanon, err := canonicalJSON(cur.Schema) + if err != nil { + // A stored schema that is not valid JSON still round-trips as its own + // literal, so emit it rather than dropping it. + specs = append(specs, MetaSchemaSpec{Name: name, Schema: cur.Schema}) + continue + } + if defCanon, err := canonicalJSON(defaults[name]); err == nil && defCanon == curCanon { + continue + } + specs = append(specs, MetaSchemaSpec{Name: name, Schema: cur.Schema}) + } + return specs, nil +} diff --git a/internal/reconcile/metaschema_test.go b/internal/reconcile/metaschema_test.go new file mode 100644 index 0000000000..e49d66ccdb --- /dev/null +++ b/internal/reconcile/metaschema_test.go @@ -0,0 +1,109 @@ +package reconcile + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// small controlled default set for the pure tests +var testMetaDefaults = map[string]string{ + "user": `{"type":"object"}`, + "organization": `{"type":"object","properties":{}}`, +} + +func TestValidateMetaSchemaSpecs(t *testing.T) { + t.Run("accepts a known built-in", func(t *testing.T) { + err := validateMetaSchemaSpecs([]MetaSchemaSpec{{Name: "user", Schema: `{"type":"object"}`}}, testMetaDefaults) + assert.NoError(t, err) + }) + t.Run("rejects an unknown name", func(t *testing.T) { + err := validateMetaSchemaSpecs([]MetaSchemaSpec{{Name: "widget", Schema: `{}`}}, testMetaDefaults) + assert.ErrorContains(t, err, "unknown metaschema") + }) + t.Run("rejects a duplicate name", func(t *testing.T) { + err := validateMetaSchemaSpecs([]MetaSchemaSpec{ + {Name: "user", Schema: `{}`}, + {Name: "user", Schema: `{}`}, + }, testMetaDefaults) + assert.ErrorContains(t, err, "more than once") + }) + t.Run("rejects an empty schema", func(t *testing.T) { + err := validateMetaSchemaSpecs([]MetaSchemaSpec{{Name: "user", Schema: " "}}, testMetaDefaults) + assert.ErrorContains(t, err, "schema is required") + }) + t.Run("rejects invalid JSON", func(t *testing.T) { + err := validateMetaSchemaSpecs([]MetaSchemaSpec{{Name: "user", Schema: "{not json"}}, testMetaDefaults) + assert.ErrorContains(t, err, "not valid JSON") + }) +} + +func TestDiffMetaSchemas(t *testing.T) { + t.Run("sets a built-in the file overrides", func(t *testing.T) { + ops, err := diffMetaSchemas( + []MetaSchemaSpec{{Name: "user", Schema: `{"type":"object","required":["x"]}`}}, + []currentMetaSchema{{ID: "user-id", Name: "user", Schema: `{"type":"object"}`}, {ID: "org-id", Name: "organization", Schema: `{"type":"object","properties":{}}`}}, + testMetaDefaults, + ) + assert.NoError(t, err) + assert.Equal(t, []string{"set metaschema user"}, planStrings(ops)) + assert.Equal(t, "user-id", ops[0].id) + }) + t.Run("no op when the file matches the server, ignoring whitespace", func(t *testing.T) { + ops, err := diffMetaSchemas( + []MetaSchemaSpec{{Name: "user", Schema: "{ \"type\": \"object\" }"}}, + []currentMetaSchema{{ID: "user-id", Name: "user", Schema: `{"type":"object"}`}, {ID: "org-id", Name: "organization", Schema: `{"type":"object","properties":{}}`}}, + testMetaDefaults, + ) + assert.NoError(t, err) + assert.Empty(t, ops) + }) + t.Run("resets a built-in the file leaves out", func(t *testing.T) { + ops, err := diffMetaSchemas( + nil, + []currentMetaSchema{{ID: "user-id", Name: "user", Schema: `{"type":"object"}`}, {ID: "org-id", Name: "organization", Schema: `{"type":"string"}`}}, + testMetaDefaults, + ) + assert.NoError(t, err) + assert.Equal(t, []string{"reset metaschema organization to default"}, planStrings(ops)) + assert.Equal(t, "org-id", ops[0].id) + }) + t.Run("creates a built-in missing on the server", func(t *testing.T) { + ops, err := diffMetaSchemas( + nil, + []currentMetaSchema{{ID: "org-id", Name: "organization", Schema: `{"type":"object","properties":{}}`}}, + testMetaDefaults, + ) + assert.NoError(t, err) + assert.Equal(t, []string{"create metaschema user"}, planStrings(ops)) + assert.Equal(t, "", ops[0].id) + }) +} + +func TestExportMetaSchemas(t *testing.T) { + current := []currentMetaSchema{ + {ID: "user-id", Name: "user", Schema: `{"type":"object","required":["x"]}`}, // overridden + {ID: "org-id", Name: "organization", Schema: `{"type":"object","properties":{}}`}, // at default + } + t.Run("emits only overridden built-ins", func(t *testing.T) { + specs, err := exportMetaSchemas(current, testMetaDefaults) + assert.NoError(t, err) + assert.Equal(t, []MetaSchemaSpec{{Name: "user", Schema: `{"type":"object","required":["x"]}`}}, specs) + }) + t.Run("reconciling an export plans zero changes", func(t *testing.T) { + specs, err := exportMetaSchemas(current, testMetaDefaults) + assert.NoError(t, err) + ops, err := diffMetaSchemas(specs, current, testMetaDefaults) + assert.NoError(t, err) + assert.Empty(t, ops) + }) +} + +// planStrings is a test helper: the String() of each op, in order. +func planStrings(ops []metaSchemaOp) []string { + out := make([]string, 0, len(ops)) + for _, op := range ops { + out = append(out, op.String()) + } + return out +} From 32e469235194b0ea0db66af8fbd1044a5643dfd0 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Thu, 13 Aug 2026 16:24:30 +0530 Subject: [PATCH 03/10] test(reconcile): strengthen metaschema normalization and export coverage --- internal/reconcile/metaschema_test.go | 48 +++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/internal/reconcile/metaschema_test.go b/internal/reconcile/metaschema_test.go index e49d66ccdb..7eb97d81f9 100644 --- a/internal/reconcile/metaschema_test.go +++ b/internal/reconcile/metaschema_test.go @@ -58,6 +58,15 @@ func TestDiffMetaSchemas(t *testing.T) { assert.NoError(t, err) assert.Empty(t, ops) }) + t.Run("no op when the file and server differ only in key order and whitespace", func(t *testing.T) { + ops, err := diffMetaSchemas( + []MetaSchemaSpec{{Name: "user", Schema: `{"a":1,"b":2}`}}, + []currentMetaSchema{{ID: "user-id", Name: "user", Schema: `{ "b": 2, "a": 1 }`}, {ID: "org-id", Name: "organization", Schema: `{"type":"object","properties":{}}`}}, + testMetaDefaults, + ) + assert.NoError(t, err) + assert.Empty(t, ops) + }) t.Run("resets a built-in the file leaves out", func(t *testing.T) { ops, err := diffMetaSchemas( nil, @@ -80,6 +89,27 @@ func TestDiffMetaSchemas(t *testing.T) { }) } +func TestCanonicalJSON(t *testing.T) { + t.Run("equal after whitespace normalization", func(t *testing.T) { + got, err := canonicalJSON(`{ "type" : "object" }`) + assert.NoError(t, err) + want, err := canonicalJSON(`{"type":"object"}`) + assert.NoError(t, err) + assert.Equal(t, want, got) + }) + t.Run("equal after key-order normalization", func(t *testing.T) { + got, err := canonicalJSON(`{"b":2,"a":1}`) + assert.NoError(t, err) + want, err := canonicalJSON(`{"a":1,"b":2}`) + assert.NoError(t, err) + assert.Equal(t, want, got) + }) + t.Run("rejects invalid JSON", func(t *testing.T) { + _, err := canonicalJSON("{not json") + assert.Error(t, err) + }) +} + func TestExportMetaSchemas(t *testing.T) { current := []currentMetaSchema{ {ID: "user-id", Name: "user", Schema: `{"type":"object","required":["x"]}`}, // overridden @@ -97,6 +127,24 @@ func TestExportMetaSchemas(t *testing.T) { assert.NoError(t, err) assert.Empty(t, ops) }) + t.Run("omits a default missing from the current server state", func(t *testing.T) { + defaults := map[string]string{ + "user": `{"type":"object"}`, + "group": `{"type":"object"}`, + } + currentMissingGroup := []currentMetaSchema{ + {ID: "user-id", Name: "user", Schema: `{"type":"object","required":["x"]}`}, + } + specs, err := exportMetaSchemas(currentMissingGroup, defaults) + assert.NoError(t, err) + assert.Equal(t, []MetaSchemaSpec{{Name: "user", Schema: `{"type":"object","required":["x"]}`}}, specs) + }) + t.Run("emits a stored schema verbatim when it is not valid JSON", func(t *testing.T) { + badCurrent := []currentMetaSchema{{ID: "user-id", Name: "user", Schema: "{not json"}} + specs, err := exportMetaSchemas(badCurrent, testMetaDefaults) + assert.NoError(t, err) + assert.Equal(t, []MetaSchemaSpec{{Name: "user", Schema: "{not json"}}, specs) + }) } // planStrings is a test helper: the String() of each op, in order. From 1ba48d4ef28d5ff72e0434426f89b8b583b3606a Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Thu, 13 Aug 2026 16:31:06 +0530 Subject: [PATCH 04/10] feat(reconcile): add the MetaSchema reconciler --- internal/reconcile/metaschema_reconciler.go | 112 +++++++++++++++ .../reconcile/metaschema_reconciler_test.go | 132 ++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 internal/reconcile/metaschema_reconciler.go create mode 100644 internal/reconcile/metaschema_reconciler_test.go diff --git a/internal/reconcile/metaschema_reconciler.go b/internal/reconcile/metaschema_reconciler.go new file mode 100644 index 0000000000..b3c20a7e53 --- /dev/null +++ b/internal/reconcile/metaschema_reconciler.go @@ -0,0 +1,112 @@ +package reconcile + +import ( + "context" + "fmt" + + "connectrpc.com/connect" + "github.com/raystack/frontier/core/metaschema" + frontierv1beta1 "github.com/raystack/frontier/proto/v1beta1" +) + +// MetaSchemaAPI is the API subset the metaschema reconciler needs. Every call +// lives on FrontierService; the caller provides one value that serves it. There +// is no delete: built-in metaschemas are values, reset but never removed. +type MetaSchemaAPI interface { + ListMetaSchemas(context.Context, *connect.Request[frontierv1beta1.ListMetaSchemasRequest]) (*connect.Response[frontierv1beta1.ListMetaSchemasResponse], error) + CreateMetaSchema(context.Context, *connect.Request[frontierv1beta1.CreateMetaSchemaRequest]) (*connect.Response[frontierv1beta1.CreateMetaSchemaResponse], error) + UpdateMetaSchema(context.Context, *connect.Request[frontierv1beta1.UpdateMetaSchemaRequest]) (*connect.Response[frontierv1beta1.UpdateMetaSchemaResponse], error) +} + +// MetaSchemaReconciler makes the built-in metaschemas match the desired spec. The +// name is the identity; the JSON schema is the managed value. Built-ins are +// values: one left out of the file resets to its shipped default. +type MetaSchemaReconciler struct { + client MetaSchemaAPI + header string + defaults map[string]string +} + +func NewMetaSchemaReconciler(client MetaSchemaAPI, header string) *MetaSchemaReconciler { + return &MetaSchemaReconciler{client: client, header: header, defaults: metaschema.Defaults} +} + +func (r *MetaSchemaReconciler) Kind() string { return KindMetaSchema } + +// Validate checks every entry without touching the server, so a bad entry stops +// the whole file before anything applies. +func (r *MetaSchemaReconciler) Validate(spec []byte) error { + var specs []MetaSchemaSpec + if err := decodeSpec(spec, &specs); err != nil { + return fmt.Errorf("parse %s spec: %w", KindMetaSchema, err) + } + return validateMetaSchemaSpecs(specs, r.defaults) +} + +func (r *MetaSchemaReconciler) Reconcile(ctx context.Context, spec []byte, dryRun bool) (Report, error) { + var specs []MetaSchemaSpec + if err := decodeSpec(spec, &specs); err != nil { + return Report{}, fmt.Errorf("parse %s spec: %w", KindMetaSchema, err) + } + + current, err := r.fetchCurrent(ctx) + if err != nil { + return Report{}, err + } + + ops, err := diffMetaSchemas(specs, current, r.defaults) + if err != nil { + return Report{}, err + } + + rep := Report{Kind: KindMetaSchema, DryRun: dryRun} + for _, op := range ops { + rep.Planned = append(rep.Planned, op.String()) + } + if dryRun { + return rep, nil + } + for _, op := range ops { + if err := r.apply(ctx, op); err != nil { + return rep, fmt.Errorf("apply [%s]: %w", op, err) + } + rep.Applied++ + } + return rep, nil +} + +// Export returns the built-ins whose schema differs from the default as a +// desired-state spec, so reconciling it plans no changes. +func (r *MetaSchemaReconciler) Export(ctx context.Context) (any, error) { + current, err := r.fetchCurrent(ctx) + if err != nil { + return nil, err + } + return exportMetaSchemas(current, r.defaults) +} + +func (r *MetaSchemaReconciler) fetchCurrent(ctx context.Context) ([]currentMetaSchema, error) { + resp, err := r.client.ListMetaSchemas(ctx, authReq(&frontierv1beta1.ListMetaSchemasRequest{}, r.header)) + if err != nil { + return nil, fmt.Errorf("list metaschemas: %w", err) + } + var current []currentMetaSchema + for _, ms := range resp.Msg.GetMetaschemas() { + current = append(current, currentMetaSchema{ + ID: ms.GetId(), + Name: ms.GetName(), + Schema: ms.GetSchema(), + }) + } + return current, nil +} + +func (r *MetaSchemaReconciler) apply(ctx context.Context, op metaSchemaOp) error { + body := &frontierv1beta1.MetaSchemaRequestBody{Name: op.name, Schema: op.schema} + if op.id == "" { + _, err := r.client.CreateMetaSchema(ctx, authReq(&frontierv1beta1.CreateMetaSchemaRequest{Body: body}, r.header)) + return err + } + _, err := r.client.UpdateMetaSchema(ctx, authReq(&frontierv1beta1.UpdateMetaSchemaRequest{Id: op.id, Body: body}, r.header)) + return err +} diff --git a/internal/reconcile/metaschema_reconciler_test.go b/internal/reconcile/metaschema_reconciler_test.go new file mode 100644 index 0000000000..87ec23bbdf --- /dev/null +++ b/internal/reconcile/metaschema_reconciler_test.go @@ -0,0 +1,132 @@ +package reconcile + +import ( + "context" + "testing" + + "connectrpc.com/connect" + "github.com/raystack/frontier/core/metaschema" + frontierv1beta1 "github.com/raystack/frontier/proto/v1beta1" + "github.com/stretchr/testify/assert" +) + +type updateCall struct { + id string + name string + schema string +} + +type fakeMetaSchemaAPI struct { + schemas []*frontierv1beta1.MetaSchema + created []*frontierv1beta1.MetaSchemaRequestBody + updated []updateCall +} + +func (f *fakeMetaSchemaAPI) ListMetaSchemas(_ context.Context, _ *connect.Request[frontierv1beta1.ListMetaSchemasRequest]) (*connect.Response[frontierv1beta1.ListMetaSchemasResponse], error) { + return connect.NewResponse(&frontierv1beta1.ListMetaSchemasResponse{Metaschemas: f.schemas}), nil +} + +func (f *fakeMetaSchemaAPI) CreateMetaSchema(_ context.Context, req *connect.Request[frontierv1beta1.CreateMetaSchemaRequest]) (*connect.Response[frontierv1beta1.CreateMetaSchemaResponse], error) { + f.created = append(f.created, req.Msg.GetBody()) + return connect.NewResponse(&frontierv1beta1.CreateMetaSchemaResponse{}), nil +} + +func (f *fakeMetaSchemaAPI) UpdateMetaSchema(_ context.Context, req *connect.Request[frontierv1beta1.UpdateMetaSchemaRequest]) (*connect.Response[frontierv1beta1.UpdateMetaSchemaResponse], error) { + f.updated = append(f.updated, updateCall{id: req.Msg.GetId(), name: req.Msg.GetBody().GetName(), schema: req.Msg.GetBody().GetSchema()}) + return connect.NewResponse(&frontierv1beta1.UpdateMetaSchemaResponse{}), nil +} + +// seededDefaults returns the five built-ins at their default schema, each with a +// stable fake id of "-id". +func seededDefaults() []*frontierv1beta1.MetaSchema { + var out []*frontierv1beta1.MetaSchema + for name, schema := range metaschema.Defaults { + out = append(out, &frontierv1beta1.MetaSchema{Id: name + "-id", Name: name, Schema: schema}) + } + return out +} + +func TestMetaSchemaReconciler(t *testing.T) { + t.Run("updates a built-in the file overrides", func(t *testing.T) { + api := &fakeMetaSchemaAPI{schemas: seededDefaults()} + spec := []byte("- {name: organization, schema: '{\"type\":\"object\",\"required\":[\"cost_center\"]}'}\n") + + rep, err := NewMetaSchemaReconciler(api, "").Reconcile(context.Background(), spec, false) + + assert.NoError(t, err) + assert.Equal(t, []string{"set metaschema organization"}, rep.Planned) + assert.Equal(t, 1, rep.Applied) + assert.Empty(t, api.created) + assert.Len(t, api.updated, 1) + assert.Equal(t, "organization-id", api.updated[0].id) + }) + + t.Run("dry run plans but applies nothing", func(t *testing.T) { + api := &fakeMetaSchemaAPI{schemas: seededDefaults()} + spec := []byte("- {name: organization, schema: '{\"type\":\"object\",\"required\":[\"cost_center\"]}'}\n") + + rep, err := NewMetaSchemaReconciler(api, "").Reconcile(context.Background(), spec, true) + + assert.NoError(t, err) + assert.Equal(t, []string{"set metaschema organization"}, rep.Planned) + assert.Equal(t, 0, rep.Applied) + assert.Empty(t, api.updated) + }) + + t.Run("resets a built-in that holds an override and is left out", func(t *testing.T) { + seeded := seededDefaults() + for _, ms := range seeded { + if ms.GetName() == metaschema.NameOrg { + ms.Schema = `{"type":"string"}` // an override + } + } + api := &fakeMetaSchemaAPI{schemas: seeded} + + rep, err := NewMetaSchemaReconciler(api, "").Reconcile(context.Background(), []byte("[]\n"), false) + + assert.NoError(t, err) + assert.Equal(t, []string{"reset metaschema organization to default"}, rep.Planned) + assert.Len(t, api.updated, 1) + assert.Equal(t, metaschema.Defaults[metaschema.NameOrg], api.updated[0].schema) + }) + + t.Run("creates a built-in missing on the server", func(t *testing.T) { + var seeded []*frontierv1beta1.MetaSchema + for _, ms := range seededDefaults() { + if ms.GetName() == metaschema.NameProspect { + continue // server does not have it yet + } + seeded = append(seeded, ms) + } + api := &fakeMetaSchemaAPI{schemas: seeded} + + rep, err := NewMetaSchemaReconciler(api, "").Reconcile(context.Background(), []byte("[]\n"), false) + + assert.NoError(t, err) + assert.Equal(t, []string{"create metaschema prospect"}, rep.Planned) + assert.Len(t, api.created, 1) + assert.Equal(t, metaschema.NameProspect, api.created[0].GetName()) + }) + + t.Run("rejects an unknown name at validate", func(t *testing.T) { + err := NewMetaSchemaReconciler(&fakeMetaSchemaAPI{}, "").Validate([]byte("- {name: widget, schema: '{}'}\n")) + assert.ErrorContains(t, err, "unknown metaschema") + }) + + t.Run("exports only overridden built-ins", func(t *testing.T) { + seeded := seededDefaults() + for _, ms := range seeded { + if ms.GetName() == metaschema.NameOrg { + ms.Schema = `{"type":"object","required":["cost_center"]}` + } + } + api := &fakeMetaSchemaAPI{schemas: seeded} + + out, err := NewMetaSchemaReconciler(api, "").Export(context.Background()) + + assert.NoError(t, err) + specs, ok := out.([]MetaSchemaSpec) + assert.True(t, ok) + assert.Equal(t, []MetaSchemaSpec{{Name: metaschema.NameOrg, Schema: `{"type":"object","required":["cost_center"]}`}}, specs) + }) +} From b1dd75f0946bd6ef42cf90c423dfb17a5b3c10bf Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Thu, 13 Aug 2026 16:39:22 +0530 Subject: [PATCH 05/10] feat(reconcile): register the MetaSchema kind and document it --- cmd/reconcile.go | 1 + docs/content/docs/reconcile.mdx | 25 ++++++++++++++++++++++++- docs/rfcs/0001-declarative-reconcile.md | 8 +++++++- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/cmd/reconcile.go b/cmd/reconcile.go index 24d3f368b1..3d36b2966d 100644 --- a/cmd/reconcile.go +++ b/cmd/reconcile.go @@ -94,6 +94,7 @@ func buildReconcileRegistry(host, header string) (map[string]reconcile.Reconcile reconcile.KindWebhook: reconcile.NewWebhookReconciler(adminClient, header), reconcile.KindBillingProduct: reconcile.NewBillingProductReconciler(api, header), reconcile.KindBillingPlan: reconcile.NewBillingPlanReconciler(api, header), + reconcile.KindMetaSchema: reconcile.NewMetaSchemaReconciler(api, header), }, nil } diff --git a/docs/content/docs/reconcile.mdx b/docs/content/docs/reconcile.mdx index be620dd018..99fdde42b0 100644 --- a/docs/content/docs/reconcile.mdx +++ b/docs/content/docs/reconcile.mdx @@ -358,6 +358,29 @@ spec: timestamps, and metadata are server-owned or out of scope and not written; out-of-scope plans are left out too. +## The MetaSchema kind + +`MetaSchema` manages the JSON schemas that validate the `metadata` field on the +built-in entities: user, group, organization, role, and prospect. Each is a +value: the file sets its schema, and one left out resets to the shipped default. +There is no delete flag, and a name outside the built-in set is rejected. + +```yaml +apiVersion: v1 +kind: MetaSchema +spec: + - name: organization + schema: | + { + "type": "object", + "properties": { "cost_center": { "type": "string" } }, + "required": ["cost_center"] + } +``` + +The schema is a JSON string. Export writes only the built-ins whose schema differs +from the default, so a freshly exported file lists just what an operator changed. + ## Running it Log in as a superuser. The bootstrap service user exists for exactly this; its client id @@ -401,7 +424,7 @@ The kind argument is case-insensitive and accepts a plural, so `platformuser` an ## More kinds This page covers `PlatformUser`, `Permission`, `Role`, `Preference`, `Webhook`, -`BillingProduct`, and `BillingPlan`. The design and +`BillingProduct`, `BillingPlan`, and `MetaSchema`. The design and the rules every kind follows live in [RFC 0001](https://github.com/raystack/frontier/blob/main/docs/rfcs/0001-declarative-reconcile.md), which also lists the kinds proposed next. The flag reference for both commands is in the diff --git a/docs/rfcs/0001-declarative-reconcile.md b/docs/rfcs/0001-declarative-reconcile.md index 96e3c41303..4cbba0ddd2 100644 --- a/docs/rfcs/0001-declarative-reconcile.md +++ b/docs/rfcs/0001-declarative-reconcile.md @@ -56,6 +56,7 @@ role is a value. | Role, predefined | value | name | reset to the shipped definition | cannot be removed | | Preference | value | trait name | reset to the trait default | leave the entry out, it resets | | Webhook | object | URL | plan fails | set `delete: true` | +| MetaSchema | value | name | reset to the shipped schema | leave the entry out, it resets | Every kind, current and future, follows the same five rules: @@ -229,6 +230,12 @@ identity. An empty event set means all events, which is the server default. The is server-owned: the server makes it on create and never returns it, so it is never in the file, a plan, or an export. +**MetaSchema.** An entry is `{name, schema}`. The name is one of the built-in +schemas the server validates entity metadata against: user, group, organization, +role, and prospect. Each is a value whose default is the shipped schema. The file +sets a schema; a built-in left out resets to its default. There is no delete flag, +and a name outside the built-in set is rejected. The schema is a JSON string. + ## Server-side changes Two boot behaviors changed to make this flow work. @@ -278,7 +285,6 @@ roles, committed as the desired-state files, then dropping the setting from the - A read-only API that returns the server's own predefined-role definitions, so the reset target comes from the running server instead of the CLI's compiled copy. This removes the image-version coupling. -- Metaschemas as a kind, once the server stops caching them per pod at boot. - Passing the auth token without putting it in the process arguments. - Billing plans as a kind, replacing the boot-time plans loader. - Removing relation-based ownership from the base schema, so narrowing a predefined role From f1a63a32f9cd428bed0fe9d89fc97308f0194319 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Thu, 13 Aug 2026 16:58:52 +0530 Subject: [PATCH 06/10] docs(reconcile): list MetaSchema in the CLI help and note pod propagation --- cmd/reconcile.go | 11 ++++++----- docs/content/docs/reconcile.mdx | 2 ++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/cmd/reconcile.go b/cmd/reconcile.go index 3d36b2966d..5037bfe10d 100644 --- a/cmd/reconcile.go +++ b/cmd/reconcile.go @@ -25,11 +25,12 @@ func ReconcileCommand(cliConfig *Config) *cli.Command { Kinds: PlatformUser (platform admins and members), Permission (custom permissions), Role (platform-level roles), Preference (platform settings), Webhook (webhook endpoints), BillingProduct (billing products - and their prices), and BillingPlan (billing plans and the products they - bundle). Deleting a permission, a custom role, or a webhook needs an - explicit 'delete: true' on its entry; nothing is deleted by omission, a - predefined role cannot be deleted, and a product or plan cannot be deleted - through the API. A preference left out of the file resets to its default. + and their prices), BillingPlan (billing plans and the products they + bundle), and MetaSchema (metadata validation schemas). Deleting a + permission, a custom role, or a webhook needs an explicit 'delete: true' + on its entry; nothing is deleted by omission, a predefined role cannot be + deleted, and a product or plan cannot be deleted through the API. A + preference left out of the file resets to its default. Log in as a superuser (for example the bootstrap service account) with --header. diff --git a/docs/content/docs/reconcile.mdx b/docs/content/docs/reconcile.mdx index 99fdde42b0..5a786e7448 100644 --- a/docs/content/docs/reconcile.mdx +++ b/docs/content/docs/reconcile.mdx @@ -381,6 +381,8 @@ spec: The schema is a JSON string. Export writes only the built-ins whose schema differs from the default, so a freshly exported file lists just what an operator changed. +A metaschema change reaches every pod within the server's cache refresh interval, about a minute by default, so it can take that long to take effect everywhere. + ## Running it Log in as a superuser. The bootstrap service user exists for exactly this; its client id From 6b7a621b97583f341afc4c77c9af8eef10f1140a Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Fri, 14 Aug 2026 11:31:36 +0530 Subject: [PATCH 07/10] fix(reconcile): reject non-object metaschemas, keep number precision, match names case-insensitively --- internal/reconcile/metaschema.go | 60 ++++++++++++++++++++++----- internal/reconcile/metaschema_test.go | 31 ++++++++++++++ 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/internal/reconcile/metaschema.go b/internal/reconcile/metaschema.go index aaa4deca4c..810384618c 100644 --- a/internal/reconcile/metaschema.go +++ b/internal/reconcile/metaschema.go @@ -3,6 +3,7 @@ package reconcile import ( "encoding/json" "fmt" + "io" "sort" "strings" ) @@ -45,12 +46,30 @@ func (o metaSchemaOp) String() string { } } +// decodeJSON parses a single JSON value, preserving number literals so a large +// or high-precision number is not rounded through float64. It rejects trailing +// data after the value, matching a strict single-document parse. +func decodeJSON(s string) (any, error) { + dec := json.NewDecoder(strings.NewReader(s)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + return nil, err + } + if _, err := dec.Token(); err != io.EOF { + return nil, fmt.Errorf("unexpected trailing data after JSON value") + } + return v, nil +} + // canonicalJSON returns a stable form of a JSON document, so two schemas that -// differ only in whitespace or key order compare equal. It keeps the export -// round-trip stable and stops formatting from making a false diff. +// differ only in whitespace or key order compare equal, while a real difference +// in a number is kept (numbers are compared by their literal, not by float64). +// It keeps the export round-trip stable and stops formatting from making a false +// diff. func canonicalJSON(s string) (string, error) { - var v any - if err := json.Unmarshal([]byte(s), &v); err != nil { + v, err := decodeJSON(s) + if err != nil { return "", err } b, err := json.Marshal(v) @@ -60,18 +79,37 @@ func canonicalJSON(s string) (string, error) { return string(b), nil } +// validateSchemaDocument checks the string is a JSON object, not just any valid +// JSON. A non-object root (a number, string, boolean, or array) parses as JSON +// but is not a usable metadata schema: the server compiles it with gojsonschema +// for every entity of that type, and a non-object root errors there, which would +// start failing all metadata writes for that entity. Catching it here keeps the +// whole file from partially applying. +func validateSchemaDocument(schema string) error { + root, err := decodeJSON(schema) + if err != nil { + return fmt.Errorf("schema is not valid JSON: %w", err) + } + if _, ok := root.(map[string]any); !ok { + return fmt.Errorf("schema must be a JSON object") + } + return nil +} + // validateMetaSchemaSpecs checks every entry without touching the server: the -// name is a known built-in, no name repeats, and the schema is non-empty valid -// JSON. defaults is the managed set and the source of known names. +// name is a known built-in, no name repeats, and the schema is a non-empty JSON +// object. defaults is the managed set and the source of known names. Names are +// matched case-insensitively, matching the sibling kinds, so `Organization` and +// `organization` both name the same built-in. func validateMetaSchemaSpecs(specs []MetaSchemaSpec, defaults map[string]string) error { seen := map[string]struct{}{} for _, s := range specs { - name := strings.TrimSpace(s.Name) + name := strings.ToLower(strings.TrimSpace(s.Name)) if name == "" { return fmt.Errorf("metaschema name is required") } if _, ok := defaults[name]; !ok { - return fmt.Errorf("unknown metaschema %q", name) + return fmt.Errorf("unknown metaschema %q", s.Name) } if _, dup := seen[name]; dup { return fmt.Errorf("metaschema %q is listed more than once", name) @@ -80,8 +118,8 @@ func validateMetaSchemaSpecs(specs []MetaSchemaSpec, defaults map[string]string) if strings.TrimSpace(s.Schema) == "" { return fmt.Errorf("metaschema %q: schema is required", name) } - if _, err := canonicalJSON(s.Schema); err != nil { - return fmt.Errorf("metaschema %q: schema is not valid JSON: %w", name, err) + if err := validateSchemaDocument(s.Schema); err != nil { + return fmt.Errorf("metaschema %q: %w", name, err) } } return nil @@ -95,7 +133,7 @@ func validateMetaSchemaSpecs(specs []MetaSchemaSpec, defaults map[string]string) func diffMetaSchemas(desired []MetaSchemaSpec, current []currentMetaSchema, defaults map[string]string) ([]metaSchemaOp, error) { desiredByName := make(map[string]string, len(desired)) for _, s := range desired { - desiredByName[strings.TrimSpace(s.Name)] = s.Schema + desiredByName[strings.ToLower(strings.TrimSpace(s.Name))] = s.Schema } currentByName := make(map[string]currentMetaSchema, len(current)) for _, c := range current { diff --git a/internal/reconcile/metaschema_test.go b/internal/reconcile/metaschema_test.go index 7eb97d81f9..f2d2078b8e 100644 --- a/internal/reconcile/metaschema_test.go +++ b/internal/reconcile/metaschema_test.go @@ -36,6 +36,16 @@ func TestValidateMetaSchemaSpecs(t *testing.T) { err := validateMetaSchemaSpecs([]MetaSchemaSpec{{Name: "user", Schema: "{not json"}}, testMetaDefaults) assert.ErrorContains(t, err, "not valid JSON") }) + t.Run("rejects a non-object schema", func(t *testing.T) { + for _, bad := range []string{"123", `"x"`, "[]", "true"} { + err := validateMetaSchemaSpecs([]MetaSchemaSpec{{Name: "user", Schema: bad}}, testMetaDefaults) + assert.ErrorContains(t, err, "must be a JSON object", "schema %q should be rejected", bad) + } + }) + t.Run("accepts a built-in name in any case", func(t *testing.T) { + err := validateMetaSchemaSpecs([]MetaSchemaSpec{{Name: "Organization", Schema: `{"type":"object"}`}}, testMetaDefaults) + assert.NoError(t, err) + }) } func TestDiffMetaSchemas(t *testing.T) { @@ -87,6 +97,16 @@ func TestDiffMetaSchemas(t *testing.T) { assert.Equal(t, []string{"create metaschema user"}, planStrings(ops)) assert.Equal(t, "", ops[0].id) }) + t.Run("matches a built-in name case-insensitively", func(t *testing.T) { + ops, err := diffMetaSchemas( + []MetaSchemaSpec{{Name: "Organization", Schema: `{"type":"object","required":["x"]}`}}, + []currentMetaSchema{{ID: "user-id", Name: "user", Schema: `{"type":"object"}`}, {ID: "org-id", Name: "organization", Schema: `{"type":"object","properties":{}}`}}, + testMetaDefaults, + ) + assert.NoError(t, err) + assert.Equal(t, []string{"set metaschema organization"}, planStrings(ops)) + assert.Equal(t, "org-id", ops[0].id) + }) } func TestCanonicalJSON(t *testing.T) { @@ -108,6 +128,17 @@ func TestCanonicalJSON(t *testing.T) { _, err := canonicalJSON("{not json") assert.Error(t, err) }) + t.Run("keeps a real difference in a large integer", func(t *testing.T) { + a, err := canonicalJSON(`{"maximum":10000000000000001}`) + assert.NoError(t, err) + b, err := canonicalJSON(`{"maximum":10000000000000002}`) + assert.NoError(t, err) + assert.NotEqual(t, a, b) + }) + t.Run("rejects trailing data", func(t *testing.T) { + _, err := canonicalJSON(`{"a":1} {"b":2}`) + assert.Error(t, err) + }) } func TestExportMetaSchemas(t *testing.T) { From 3a752a285b1035f82ce2f7e5ba7e55211fd6aca9 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Fri, 14 Aug 2026 11:56:42 +0530 Subject: [PATCH 08/10] test(reconcile): cover the MetaSchema kind against the five RFC rules --- .../reconcile/metaschema_reconciler_test.go | 14 +++++ internal/reconcile/metaschema_test.go | 63 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/internal/reconcile/metaschema_reconciler_test.go b/internal/reconcile/metaschema_reconciler_test.go index 87ec23bbdf..d937f4c3af 100644 --- a/internal/reconcile/metaschema_reconciler_test.go +++ b/internal/reconcile/metaschema_reconciler_test.go @@ -113,6 +113,20 @@ func TestMetaSchemaReconciler(t *testing.T) { assert.ErrorContains(t, err, "unknown metaschema") }) + // R2 value model: there is no delete, so a delete flag is an unknown field. + t.Run("R2 rejects a delete flag since metaschemas are values", func(t *testing.T) { + err := NewMetaSchemaReconciler(&fakeMetaSchemaAPI{}, "").Validate([]byte("- {name: organization, schema: '{\"type\":\"object\"}', delete: true}\n")) + assert.Error(t, err) + }) + + // R3 check the whole file first: one bad entry fails validation for the doc, + // so nothing applies. + t.Run("R3 a bad entry fails validation for the whole document", func(t *testing.T) { + spec := []byte("- {name: organization, schema: '{\"type\":\"object\"}'}\n- {name: widget, schema: '{\"type\":\"object\"}'}\n") + err := NewMetaSchemaReconciler(&fakeMetaSchemaAPI{}, "").Validate(spec) + assert.ErrorContains(t, err, "unknown metaschema") + }) + t.Run("exports only overridden built-ins", func(t *testing.T) { seeded := seededDefaults() for _, ms := range seeded { diff --git a/internal/reconcile/metaschema_test.go b/internal/reconcile/metaschema_test.go index f2d2078b8e..c78d669ccb 100644 --- a/internal/reconcile/metaschema_test.go +++ b/internal/reconcile/metaschema_test.go @@ -178,6 +178,69 @@ func TestExportMetaSchemas(t *testing.T) { }) } +// TestMetaSchema_RFCRules checks the metaschema kind against the five rules of +// RFC 0001, at the pure diff/validate/export layer. +func TestMetaSchema_RFCRules(t *testing.T) { + // R1 Scope and identity: a metaschema outside the managed set is invisible. + // The diff never touches it and export never emits it. + t.Run("R1 ignores a metaschema outside the managed set", func(t *testing.T) { + current := []currentMetaSchema{ + {ID: "user-id", Name: "user", Schema: testMetaDefaults["user"]}, // at default + {ID: "org-id", Name: "organization", Schema: testMetaDefaults["organization"]}, // at default + {ID: "cust-id", Name: "custom_widget", Schema: `{"type":"object"}`}, // not a built-in + } + ops, err := diffMetaSchemas(nil, current, testMetaDefaults) + assert.NoError(t, err) + assert.Empty(t, ops) + specs, err := exportMetaSchemas(current, testMetaDefaults) + assert.NoError(t, err) + assert.Empty(t, specs) + }) + + // R4 Converge, not transact: re-reconciling a state that already matches the + // file plans nothing, so a re-run after a partial apply converges. + t.Run("R4 re-reconciling a converged state plans nothing", func(t *testing.T) { + desired := []MetaSchemaSpec{{Name: "organization", Schema: `{"type":"object","required":["cc"]}`}} + converged := []currentMetaSchema{ + {ID: "user-id", Name: "user", Schema: testMetaDefaults["user"]}, + {ID: "org-id", Name: "organization", Schema: `{"type":"object","required":["cc"]}`}, + } + ops, err := diffMetaSchemas(desired, converged, testMetaDefaults) + assert.NoError(t, err) + assert.Empty(t, ops) + }) + + // R5 Export inverts reconcile: an all-default server exports nothing, and + // reconciling that empty export plans nothing. + t.Run("R5 all-default server exports nothing and round-trips to zero", func(t *testing.T) { + current := []currentMetaSchema{ + {ID: "user-id", Name: "user", Schema: testMetaDefaults["user"]}, + {ID: "org-id", Name: "organization", Schema: testMetaDefaults["organization"]}, + } + specs, err := exportMetaSchemas(current, testMetaDefaults) + assert.NoError(t, err) + assert.Empty(t, specs) + ops, err := diffMetaSchemas(specs, current, testMetaDefaults) + assert.NoError(t, err) + assert.Empty(t, ops) + }) + + // R5 Export inverts reconcile: an overridden built-in round-trips exactly, + // including a large integer that float64 would round. + t.Run("R5 an overridden built-in round-trips, keeping number precision", func(t *testing.T) { + current := []currentMetaSchema{ + {ID: "user-id", Name: "user", Schema: testMetaDefaults["user"]}, + {ID: "org-id", Name: "organization", Schema: `{"type":"object","properties":{"n":{"maximum":10000000000000001}}}`}, + } + specs, err := exportMetaSchemas(current, testMetaDefaults) + assert.NoError(t, err) + assert.Len(t, specs, 1) + ops, err := diffMetaSchemas(specs, current, testMetaDefaults) + assert.NoError(t, err) + assert.Empty(t, ops) + }) +} + // planStrings is a test helper: the String() of each op, in order. func planStrings(ops []metaSchemaOp) []string { out := make([]string, 0, len(ops)) From 04bb0d7331ae9de6cff2e5abe39d2319b823d01f Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Fri, 14 Aug 2026 12:32:51 +0530 Subject: [PATCH 09/10] fix(reconcile): stop rejecting non-object metaschemas so exports round-trip --- internal/reconcile/metaschema.go | 33 ++++++++++----------------- internal/reconcile/metaschema_test.go | 29 +++++++++++++++++++---- 2 files changed, 37 insertions(+), 25 deletions(-) diff --git a/internal/reconcile/metaschema.go b/internal/reconcile/metaschema.go index 810384618c..90c4a79458 100644 --- a/internal/reconcile/metaschema.go +++ b/internal/reconcile/metaschema.go @@ -79,28 +79,19 @@ func canonicalJSON(s string) (string, error) { return string(b), nil } -// validateSchemaDocument checks the string is a JSON object, not just any valid -// JSON. A non-object root (a number, string, boolean, or array) parses as JSON -// but is not a usable metadata schema: the server compiles it with gojsonschema -// for every entity of that type, and a non-object root errors there, which would -// start failing all metadata writes for that entity. Catching it here keeps the -// whole file from partially applying. -func validateSchemaDocument(schema string) error { - root, err := decodeJSON(schema) - if err != nil { - return fmt.Errorf("schema is not valid JSON: %w", err) - } - if _, ok := root.(map[string]any); !ok { - return fmt.Errorf("schema must be a JSON object") - } - return nil -} - // validateMetaSchemaSpecs checks every entry without touching the server: the -// name is a known built-in, no name repeats, and the schema is a non-empty JSON -// object. defaults is the managed set and the source of known names. Names are +// name is a known built-in, no name repeats, and the schema is non-empty valid +// JSON. defaults is the managed set and the source of known names. Names are // matched case-insensitively, matching the sibling kinds, so `Organization` and // `organization` both name the same built-in. +// +// It deliberately does NOT require the schema to be a JSON object. The write API +// (CreateMetaSchema/UpdateMetaSchema) accepts any non-empty schema, so a +// non-object schema is a state the server can reach. Rejecting it here would make +// the reconciler stricter than the server, and the export of such a state would +// then fail its own re-reconcile, which breaks the rule 5 round-trip. If a +// non-object schema should be rejected, that check belongs on the write API, +// where it also stops the state from being reached in the first place. func validateMetaSchemaSpecs(specs []MetaSchemaSpec, defaults map[string]string) error { seen := map[string]struct{}{} for _, s := range specs { @@ -118,8 +109,8 @@ func validateMetaSchemaSpecs(specs []MetaSchemaSpec, defaults map[string]string) if strings.TrimSpace(s.Schema) == "" { return fmt.Errorf("metaschema %q: schema is required", name) } - if err := validateSchemaDocument(s.Schema); err != nil { - return fmt.Errorf("metaschema %q: %w", name, err) + if _, err := canonicalJSON(s.Schema); err != nil { + return fmt.Errorf("metaschema %q: schema is not valid JSON: %w", name, err) } } return nil diff --git a/internal/reconcile/metaschema_test.go b/internal/reconcile/metaschema_test.go index c78d669ccb..a75f4ce7cf 100644 --- a/internal/reconcile/metaschema_test.go +++ b/internal/reconcile/metaschema_test.go @@ -36,10 +36,13 @@ func TestValidateMetaSchemaSpecs(t *testing.T) { err := validateMetaSchemaSpecs([]MetaSchemaSpec{{Name: "user", Schema: "{not json"}}, testMetaDefaults) assert.ErrorContains(t, err, "not valid JSON") }) - t.Run("rejects a non-object schema", func(t *testing.T) { - for _, bad := range []string{"123", `"x"`, "[]", "true"} { - err := validateMetaSchemaSpecs([]MetaSchemaSpec{{Name: "user", Schema: bad}}, testMetaDefaults) - assert.ErrorContains(t, err, "must be a JSON object", "schema %q should be rejected", bad) + t.Run("accepts a non-object schema so a reachable state round-trips (rule 5)", func(t *testing.T) { + // The write API accepts any non-empty schema, so a non-object is a state + // the server can reach. The reconciler must not be stricter than that, or + // the export of such a state would fail its own re-reconcile. + for _, ok := range []string{"123", `"x"`, "[]", "true"} { + err := validateMetaSchemaSpecs([]MetaSchemaSpec{{Name: "user", Schema: ok}}, testMetaDefaults) + assert.NoError(t, err, "schema %q is valid JSON and must be accepted", ok) } }) t.Run("accepts a built-in name in any case", func(t *testing.T) { @@ -225,6 +228,24 @@ func TestMetaSchema_RFCRules(t *testing.T) { assert.Empty(t, ops) }) + // R5 Export inverts reconcile: a non-object schema is a state the server can + // reach, since the write API accepts any non-empty schema, so its export must + // round-trip. The reconciler no longer rejects it. + t.Run("R5 a non-object schema round-trips", func(t *testing.T) { + current := []currentMetaSchema{ + {ID: "user-id", Name: "user", Schema: testMetaDefaults["user"]}, + {ID: "org-id", Name: "organization", Schema: "123"}, // reachable via the raw API + } + specs, err := exportMetaSchemas(current, testMetaDefaults) + assert.NoError(t, err) + assert.Equal(t, []MetaSchemaSpec{{Name: "organization", Schema: "123"}}, specs) + err = validateMetaSchemaSpecs(specs, testMetaDefaults) + assert.NoError(t, err) + ops, err := diffMetaSchemas(specs, current, testMetaDefaults) + assert.NoError(t, err) + assert.Empty(t, ops) + }) + // R5 Export inverts reconcile: an overridden built-in round-trips exactly, // including a large integer that float64 would round. t.Run("R5 an overridden built-in round-trips, keeping number precision", func(t *testing.T) { From 8e0dd530037ea686684705b49c8662c7be7970d7 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Fri, 14 Aug 2026 12:41:54 +0530 Subject: [PATCH 10/10] fix(reconcile): restore the non-object metaschema check --- internal/reconcile/metaschema.go | 33 +++++++++++++++++---------- internal/reconcile/metaschema_test.go | 29 ++++------------------- 2 files changed, 25 insertions(+), 37 deletions(-) diff --git a/internal/reconcile/metaschema.go b/internal/reconcile/metaschema.go index 90c4a79458..810384618c 100644 --- a/internal/reconcile/metaschema.go +++ b/internal/reconcile/metaschema.go @@ -79,19 +79,28 @@ func canonicalJSON(s string) (string, error) { return string(b), nil } +// validateSchemaDocument checks the string is a JSON object, not just any valid +// JSON. A non-object root (a number, string, boolean, or array) parses as JSON +// but is not a usable metadata schema: the server compiles it with gojsonschema +// for every entity of that type, and a non-object root errors there, which would +// start failing all metadata writes for that entity. Catching it here keeps the +// whole file from partially applying. +func validateSchemaDocument(schema string) error { + root, err := decodeJSON(schema) + if err != nil { + return fmt.Errorf("schema is not valid JSON: %w", err) + } + if _, ok := root.(map[string]any); !ok { + return fmt.Errorf("schema must be a JSON object") + } + return nil +} + // validateMetaSchemaSpecs checks every entry without touching the server: the -// name is a known built-in, no name repeats, and the schema is non-empty valid -// JSON. defaults is the managed set and the source of known names. Names are +// name is a known built-in, no name repeats, and the schema is a non-empty JSON +// object. defaults is the managed set and the source of known names. Names are // matched case-insensitively, matching the sibling kinds, so `Organization` and // `organization` both name the same built-in. -// -// It deliberately does NOT require the schema to be a JSON object. The write API -// (CreateMetaSchema/UpdateMetaSchema) accepts any non-empty schema, so a -// non-object schema is a state the server can reach. Rejecting it here would make -// the reconciler stricter than the server, and the export of such a state would -// then fail its own re-reconcile, which breaks the rule 5 round-trip. If a -// non-object schema should be rejected, that check belongs on the write API, -// where it also stops the state from being reached in the first place. func validateMetaSchemaSpecs(specs []MetaSchemaSpec, defaults map[string]string) error { seen := map[string]struct{}{} for _, s := range specs { @@ -109,8 +118,8 @@ func validateMetaSchemaSpecs(specs []MetaSchemaSpec, defaults map[string]string) if strings.TrimSpace(s.Schema) == "" { return fmt.Errorf("metaschema %q: schema is required", name) } - if _, err := canonicalJSON(s.Schema); err != nil { - return fmt.Errorf("metaschema %q: schema is not valid JSON: %w", name, err) + if err := validateSchemaDocument(s.Schema); err != nil { + return fmt.Errorf("metaschema %q: %w", name, err) } } return nil diff --git a/internal/reconcile/metaschema_test.go b/internal/reconcile/metaschema_test.go index a75f4ce7cf..c78d669ccb 100644 --- a/internal/reconcile/metaschema_test.go +++ b/internal/reconcile/metaschema_test.go @@ -36,13 +36,10 @@ func TestValidateMetaSchemaSpecs(t *testing.T) { err := validateMetaSchemaSpecs([]MetaSchemaSpec{{Name: "user", Schema: "{not json"}}, testMetaDefaults) assert.ErrorContains(t, err, "not valid JSON") }) - t.Run("accepts a non-object schema so a reachable state round-trips (rule 5)", func(t *testing.T) { - // The write API accepts any non-empty schema, so a non-object is a state - // the server can reach. The reconciler must not be stricter than that, or - // the export of such a state would fail its own re-reconcile. - for _, ok := range []string{"123", `"x"`, "[]", "true"} { - err := validateMetaSchemaSpecs([]MetaSchemaSpec{{Name: "user", Schema: ok}}, testMetaDefaults) - assert.NoError(t, err, "schema %q is valid JSON and must be accepted", ok) + t.Run("rejects a non-object schema", func(t *testing.T) { + for _, bad := range []string{"123", `"x"`, "[]", "true"} { + err := validateMetaSchemaSpecs([]MetaSchemaSpec{{Name: "user", Schema: bad}}, testMetaDefaults) + assert.ErrorContains(t, err, "must be a JSON object", "schema %q should be rejected", bad) } }) t.Run("accepts a built-in name in any case", func(t *testing.T) { @@ -228,24 +225,6 @@ func TestMetaSchema_RFCRules(t *testing.T) { assert.Empty(t, ops) }) - // R5 Export inverts reconcile: a non-object schema is a state the server can - // reach, since the write API accepts any non-empty schema, so its export must - // round-trip. The reconciler no longer rejects it. - t.Run("R5 a non-object schema round-trips", func(t *testing.T) { - current := []currentMetaSchema{ - {ID: "user-id", Name: "user", Schema: testMetaDefaults["user"]}, - {ID: "org-id", Name: "organization", Schema: "123"}, // reachable via the raw API - } - specs, err := exportMetaSchemas(current, testMetaDefaults) - assert.NoError(t, err) - assert.Equal(t, []MetaSchemaSpec{{Name: "organization", Schema: "123"}}, specs) - err = validateMetaSchemaSpecs(specs, testMetaDefaults) - assert.NoError(t, err) - ops, err := diffMetaSchemas(specs, current, testMetaDefaults) - assert.NoError(t, err) - assert.Empty(t, ops) - }) - // R5 Export inverts reconcile: an overridden built-in round-trips exactly, // including a large integer that float64 would round. t.Run("R5 an overridden built-in round-trips, keeping number precision", func(t *testing.T) {