Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions cmd/reconcile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -94,6 +95,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
}

Expand Down
39 changes: 39 additions & 0 deletions core/metaschema/defaults.go
Original file line number Diff line number Diff line change
@@ -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),
}
27 changes: 27 additions & 0 deletions core/metaschema/defaults_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
27 changes: 26 additions & 1 deletion docs/content/docs/reconcile.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,31 @@ 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.

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.
Comment thread
rohilsurana marked this conversation as resolved.

## Running it

Log in as a superuser. The bootstrap service user exists for exactly this; its client id
Expand Down Expand Up @@ -401,7 +426,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
Expand Down
8 changes: 7 additions & 1 deletion docs/rfcs/0001-declarative-reconcile.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
209 changes: 209 additions & 0 deletions internal/reconcile/metaschema.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package reconcile

import (
"encoding/json"
"fmt"
"io"
"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)
}
}

// 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, 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) {
v, err := decodeJSON(s)
if err != nil {
return "", err
}
b, err := json.Marshal(v)
if err != nil {
return "", err
}
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
Comment on lines +88 to +96

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

For the repository version of github.com/xeipuuv/gojsonschema, does validating data against the schema {"type":123} return a schema-compilation error, and can schema validation be performed without resolving remote $ref targets?

💡 Result:

For the github.com/xeipuuv/gojsonschema repository, the behavior for validating against the schema {"type": 123} is as follows: 1. Schema-compilation error: No, this does not trigger a schema-compilation error by default [1]. The library treats the schema as a valid JSON object. During validation, the "type" keyword expects a string or an array of strings (per JSON Schema specifications). Because 123 is a number, the library will fail to correctly process the "type" constraint during the validation phase, typically resulting in a validation result indicating the data does not match, rather than a hard failure during the NewSchema or Compile compilation step [1]. If you enable meta-schema validation via the SchemaLoader (setting sl.Validate = true), the library may detect the schema itself is invalid against the JSON Schema meta-schema, which would then return an error during the compilation/loading phase [1]. 2. Resolving remote $ref targets: No, the library does not provide a native configuration option to disable the automatic resolution of remote $ref targets [2]. The implementation automatically attempts to fetch remote references (e.g., via http/https) when encountered [3]. Users have reported that this behavior is "baked in" and difficult to bypass without external workarounds, such as pre-loading all required schemas using AddSchema/AddSchemas to satisfy dependencies before they are fetched over the network [2][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- candidate files ---'
git ls-files | grep -E '(^|/)(metaschema\.go|service\.go|go\.mod|go\.sum)$' || true

printf '%s\n' '--- relevant symbols and calls ---'
rg -n -C 5 'validateSchemaDocument|gojsonschema|SchemaLoader|Validate|exportMetaSchemas|MetaSchemaReconciler' internal core go.mod go.sum 2>/dev/null || true

printf '%s\n' '--- targeted source excerpts ---'
for f in internal/reconcile/metaschema.go core/metaschema/service.go go.mod; do
  if [ -f "$f" ]; then
    echo "### $f"
    nl -ba "$f" | sed -n '1,180p'
  fi
done

Repository: raystack/frontier

Length of output: 50374


🏁 Script executed:

#!/bin/bash
set -eu

for f in internal/reconcile/metaschema.go internal/reconcile/metaschema_reconciler.go core/metaschema/service.go core/metaschema/metaschema.go internal/reconcile/metaschema_test.go internal/reconcile/metaschema_reconciler_test.go go.mod; do
  if [ -f "$f" ]; then
    echo "### $f"
    wc -l "$f"
    nl -ba "$f" | sed -n '1,220p'
  fi
done

echo '### gojsonschema references'
rg -n -C 8 'gojsonschema|SchemaLoader|NewSchema|Validate\(' core internal --glob '*.go' | head -n 300

echo '### local module source availability'
go env GOMODCACHE GOPATH 2>/dev/null || true
find "${GOMODCACHE:-/nonexistent}" -maxdepth 5 -type d -path '*gojsonschema*' -print 2>/dev/null | head -n 20

Repository: raystack/frontier

Length of output: 26225


🏁 Script executed:

#!/bin/bash
set -eu

for spec in \
  'internal/reconcile/metaschema.go:1,210' \
  'internal/reconcile/metaschema_reconciler.go:1,112' \
  'core/metaschema/service.go:1,117' \
  'core/metaschema/metaschema.go:1,24' \
  'internal/reconcile/metaschema_test.go:1,251' \
  'internal/reconcile/metaschema_reconciler_test.go:1,146'; do
  file=${spec%:*}
  range=${spec#*:}
  echo "### $file"
  sed -n "${range}p" "$file"
done

echo '### direct gojsonschema imports and calls'
rg -n -C 10 'github.com/xeipuuv/gojsonschema|gojsonschema\.' --glob '*.go' . || true

mod=/home/jailuser/go/pkg/mod/github.com/xeipuuv/gojsonschema@v1.2.0
echo "### gojsonschema v1.2.0 files"
find "$mod" -maxdepth 1 -type f -printf '%f\n' | sort

echo '### loader/compiler/validation implementation'
rg -n -C 12 'type SchemaLoader|Validate|NewSchema|Compile|validateSchema|typeSchema|remote|ReferenceLoader' "$mod" --glob '*.go' | head -n 500

Repository: raystack/frontier

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -eu

mod=/home/jailuser/go/pkg/mod/github.com/xeipuuv/gojsonschema@v1.2.0

echo '### SchemaLoader and Validate defaults'
sed -n '1,240p' "$mod/schemaLoader.go"
sed -n '1,260p' "$mod/schema.go"

echo '### type keyword handling'
rg -n -C 15 'Type|type.*keyword|NewType|parseType|TypeError|expected.*type' "$mod" --glob '*.go' | head -n 300

echo '### remote reference loading'
sed -n '130,240p' "$mod/schemaPool.go"
rg -n -C 15 'func \(.*\) LoadJSON|http.Get|Get\(|NewStringLoader|NewSchemaLoader|Validate =' "$mod" --glob '*.go' | head -n 300

echo '### isolated runtime probe'
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
cat > "$tmp/go.mod" <<EOF
module isolated-probe

go 1.20

require github.com/xeipuuv/gojsonschema v1.2.0
EOF

cat > "$tmp/main.go" <<'EOF'
package main

import (
	"fmt"
	"github.com/xeipuuv/gojsonschema"
)

func main() {
	schemas := []string{
		`{"type":123}`,
		`{"type":"object"}`,
		`{"$ref":"https://127.0.0.1:1/unreachable.json"}`,
	}
	for _, raw := range schemas {
		result, err := gojsonschema.Validate(
			gojsonschema.NewStringLoader(raw),
			gojsonschema.NewGoLoader(map[string]any{}),
		)
		fmt.Printf("schema=%s\nerr=%v\n", raw, err)
		if result != nil {
			fmt.Printf("valid=%v\n", result.Valid())
		}
	}
}
EOF

(
  cd "$tmp"
  GOPROXY=off GOSUMDB=off go run .
)

Repository: raystack/frontier

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -u

tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT

cat > "$tmp/go.mod" <<'EOF'
module isolated-probe

go 1.20

require github.com/xeipuuv/gojsonschema v1.2.0
EOF

cat > "$tmp/main.go" <<'EOF'
package main

import (
	"fmt"

	"github.com/xeipuuv/gojsonschema"
)

func main() {
	schemas := []string{
		`{"type":123}`,
		`{"type":["object",123]}`,
		`{"required":123}`,
		`{"properties":123}`,
		`{"items":123}`,
		`{"minimum":"x"}`,
		`{"$ref":"https://127.0.0.1:1/unreachable.json"}`,
		`{"type":"object"}`,
	}

	for _, raw := range schemas {
		result, err := gojsonschema.Validate(
			gojsonschema.NewStringLoader(raw),
			gojsonschema.NewGoLoader(map[string]any{}),
		)
		fmt.Printf("schema=%s\nerr=%v\n", raw, err)
		if result != nil {
			fmt.Printf("valid=%v errors=%d\n", result.Valid(), len(result.Errors()))
		}
	}
}
EOF

cd "$tmp"
GOPROXY=off GOSUMDB=off GOFLAGS=-mod=mod go run . 2>&1

Repository: raystack/frontier

Length of output: 392


🏁 Script executed:

#!/bin/bash
set -u

echo '### cached dependency versions'
find /home/jailuser/go/pkg/mod/github.com/xeipuuv -maxdepth 1 -mindepth 1 -type d -printf '%f\n' | sort
find /home/jailuser/go/pkg/mod/cache/download/github.com/xeipuuv -type f -name '*.mod' -o -name '*.ziphash' 2>/dev/null | sort | head -n 100

mod=/home/jailuser/go/pkg/mod/github.com/xeipuuv/gojsonschema@v1.2.0

echo '### NewStringLoader and loader factories'
rg -n -C 12 'func NewStringLoader|type stringJSONLoader|LoaderFactory|func \(.*\) LoaderFactory' "$mod" --glob '*.go'

echo '### exact malformed-keyword branches'
rg -n -C 20 'KEY_TYPE|KEY_REQUIRED|KEY_PROPERTIES|KEY_ITEMS|KEY_MINIMUM|KEY_MAXIMUM' "$mod/schema.go" | head -n 500

echo '### compile a local-replacement probe when cached modules permit it'
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT

cat > "$tmp/go.mod" <<'EOF'
module isolated-probe

go 1.20

require (
	github.com/xeipuuv/gojsonschema v1.2.0
	github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb
	github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415
)

replace github.com/xeipuuv/gojsonschema => /home/jailuser/go/pkg/mod/github.com/xeipuuv/gojsonschema@v1.2.0
replace github.com/xeipuuv/gojsonpointer => /home/jailuser/go/pkg/mod/github.com/xeipuuv/gojsonpointer@v0.0.0-20190905194746-02993c407bfb
replace github.com/xeipuuv/gojsonreference => /home/jailuser/go/pkg/mod/github.com/xeipuuv/gojsonreference@v0.0.0-20180127040603-bd5ef7bd5415
EOF

cat > "$tmp/main.go" <<'EOF'
package main

import (
	"fmt"
	"github.com/xeipuuv/gojsonschema"
)

func main() {
	for _, raw := range []string{
		`{"type":123}`,
		`{"type":["object",123]}`,
		`{"required":123}`,
		`{"properties":123}`,
		`{"items":123}`,
		`{"minimum":"x"}`,
		`{"$ref":"https://127.0.0.1:1/unreachable.json"}`,
	} {
		result, err := gojsonschema.Validate(
			gojsonschema.NewStringLoader(raw),
			gojsonschema.NewGoLoader(map[string]any{}),
		)
		fmt.Printf("schema=%s\nerr=%v\n", raw, err)
		if result != nil {
			fmt.Printf("valid=%v errors=%d\n", result.Valid(), len(result.Errors()))
		}
	}
}
EOF

(cd "$tmp" && GOPROXY=off GOSUMDB=off GOFLAGS=-mod=mod go run .) 2>&1

Repository: raystack/frontier

Length of output: 44934


🏁 Script executed:

#!/bin/bash
set -eu

mod=/home/jailuser/go/pkg/mod/github.com/xeipuuv/gojsonschema@v1.2.0

echo '### public Validate wrapper'
rg -n -C 20 '^func Validate|^func \(.*\) Validate' "$mod" --glob '*.go'

echo '### HTTP reference loader'
sed -n '132,225p' "$mod/jsonLoader.go"

echo '### reference parsing and remote resolution'
sed -n '48,125p' "$mod/schemaPool.go"
sed -n '180,225p' "$mod/schemaPool.go"

Repository: raystack/frontier

Length of output: 11302


Reject schemas that gojsonschema cannot compile before planning updates.

validateSchemaDocument accepts {"type":123}, but gojsonschema.Validate returns a schema-compilation error for this input. Metadata validation can then fail for that entity type. Use the same gojsonschema compilation rules during reconcile. Define how remote $ref values are handled because NewStringLoader resolves uncached references over HTTP.

}

// 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
// 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.ToLower(strings.TrimSpace(s.Name))
if name == "" {
return fmt.Errorf("metaschema name is required")
}
if _, ok := defaults[name]; !ok {
Comment thread
rohilsurana marked this conversation as resolved.
return fmt.Errorf("unknown metaschema %q", s.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 := validateSchemaDocument(s.Schema); err != nil {
return fmt.Errorf("metaschema %q: %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.ToLower(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
Comment on lines +196 to +201

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not export a spec that reconcile rejects.

When a stored schema is invalid JSON, this branch emits it verbatim. MetaSchemaReconciler.Validate rejects that exported value before reconcile starts. Therefore, frontier export metaschema can produce a document that its own reconcile flow cannot consume.

Return an export error with the metaschema name for invalid stored JSON, or define an exported representation that validation accepts.

Proposed fix
 		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
+			return nil, fmt.Errorf("metaschema %q contains invalid JSON: %w", name, err)
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
curCanon, err := canonicalJSON(cur.Schema)
if err != nil {
return nil, fmt.Errorf("metaschema %q contains invalid JSON: %w", name, err)
}

}
if defCanon, err := canonicalJSON(defaults[name]); err == nil && defCanon == curCanon {
continue
}
specs = append(specs, MetaSchemaSpec{Name: name, Schema: cur.Schema})
}
return specs, nil
}
Loading
Loading