schema

package
v0.1.8 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 17, 2026 License: Apache-2.0 Imports: 16 Imported by: 0

Documentation

Index

Constants

View Source
const (
	RelationHasMany    = "has_many"
	RelationBelongsTo  = "belongs_to"
	RelationManyToMany = "many_to_many"

	// DefaultEmbedLimit bounds children per parent in an embed when a relation
	// declares no Limit — a paginated embed that caps fan-out (DoS guard).
	DefaultEmbedLimit = 50
	// DefaultMaxIncludeDepth is the maximum nesting depth of ?include= (e.g.
	// "lines.product" is depth 2). Requests beyond it are rejected with 400.
	DefaultMaxIncludeDepth = 2
)

Relation type constants and embed defaults (ADR-019 §4).

View Source
const (
	OnDeleteRestrict = "restrict"
	OnDeleteCascade  = "cascade"
	OnDeleteSetNull  = "set_null"
)

On-delete referential actions for a field-level foreign key (MIG-F1-S1). An unset action defaults to RESTRICT — the safe choice (reject a delete that would orphan children, rather than silently orphaning them, the integrity bug closed here). set_null requires the FK column to be nullable.

View Source
const (
	OnUpdateRestrict = "restrict"
	OnUpdateCascade  = "cascade"
	OnUpdateSetNull  = "set_null"
)

On-update referential actions (MIG-F1-S5). The action vocabulary mirrors on_delete, but the UNSET default is NO ACTION (Postgres' own default, which the pre-S5 FKs already carry) — not RESTRICT — so introducing on_update generates no drift on existing tenants (see FieldDef.OnUpdate). set_null requires the FK column to be nullable.

View Source
const (
	IndexMethodBtree = "btree"
	IndexMethodGIN   = "gin"
)

Index access methods (LIBRARY-GAPS-S1). Empty defaults to btree — the historical behavior — so an existing schema is byte-identical.

View Source
const MaxPatternLength = 200

MaxPatternLength bounds a field's regex source. Go's regexp is RE2 (linear time, no catastrophic backtracking), so the cap only limits compile cost and schema noise, not worst-case matching behaviour.

View Source
const PublicRoleName = "$public"

validateConditionOp rejects an RBAC condition whose operator the engine does not enforce. prefix is the condition's path (e.g. "rbac.roles.x.conditions"). A nil condition or an eq/empty op produces no error. PublicRoleName is the reserved role the rbac.public block compiles into (ADR-026). It starts with a character no schema role may use, and declaring it literally in rbac.roles is rejected at load — the anonymous surface can only ever come from the public block.

Variables

View Source
var ValidHookEvents = map[string]bool{
	"before_create": true,
	"after_create":  true,
	"before_update": true,
	"after_update":  true,
}

ValidHookEvents is the set of lifecycle events the engine actually consults (codegen.BuildRouter + the GraphQL create resolver). A hook on any other event name would never fire — so any other name is a validation error.

Functions

func AsFloat64 added in v0.1.7

func AsFloat64(v any) (float64, bool)

AsFloat64 converts any Go numeric value to float64 for rule evaluation. It is the ONE place that decides what counts as a number on a write, shared by the declarative rules here and by codegen's type check, so the two cannot drift.

WHY IT IS NOT JUST float64. encoding/json decodes every JSON number into float64, and for years that was the only shape a write could arrive in — the HTTP path is the only caller. The library path (Ctx.Insert / Ctx.Update, the custom-handler surface) broke that assumption: a Go handler passes what Go computes, and what the engine RETURNS from a read is int64/int32 from the pgx driver. Rejecting int64 on a write while handing int64 back on a read makes the round trip — read a row, change a field, write it back — impossible without a manual cast, which is what a third-party consumer hit and had to diagnose ("surprising in a Go API; it cost a full rebuild cycle").

The conversion is for VALIDATION only; the caller's original value is what reaches the database, so an int64 beyond 2^53 is still stored exactly.

func EmitTopic

func EmitTopic(resource, action string) string

EmitTopic returns the outbox topic for (resource, action), e.g. EmitTopic("tasks","create") == "tasks.created". Empty if action is unknown.

func Explain added in v0.1.2

func Explain(s *APISchema, lang string) string

Explain renders the schema as plain-language prose. lang: "en" | "es" (anything else falls back to "en").

func IsIntegral added in v0.1.7

func IsIntegral(v any) bool

IsIntegral reports whether v is a whole number, for the int/int64 type check. Any Go integer type is integral by construction; a float is only integral when it has no fractional part (the 1.9-into-an-int-column case, which Postgres would silently truncate).

func MetaSchemaJSON

func MetaSchemaJSON() []byte

MetaSchemaJSON returns the raw bytes of the embedded meta-schema (for the `appximo meta-schema` command, IDE integration, and external tooling).

Types

type APISchema

type APISchema struct {
	Schema    string                    `json:"$schema"`
	Version   string                    `json:"version"`
	Name      string                    `json:"name"`
	Resources map[string]ResourceSchema `json:"resources"`
	RBAC      RBACPolicy                `json:"rbac"`
	// Workflows is reserved for the Phase 2 multi-step orchestration engine
	// (ADR-012). The struct is parsed for forward compatibility, but no executor
	// runs it yet — present so existing schemas remain valid once it ships.
	Workflows map[string]WorkflowSchema `json:"workflows,omitempty"`
}

APISchema is the top-level contract for an Appximo project.

func LoadFromBytes

func LoadFromBytes(data []byte) (*APISchema, error)

LoadFromBytes parses and structurally checks a schema from raw JSON — the exact checks LoadFromFile performs (strict keys, required $schema/version), for callers that receive the schema over the wire (e.g. the engine self-restart persist, UI-F4-S2) instead of from a file.

func LoadFromFile

func LoadFromFile(path string) (*APISchema, error)

type Condition

type Condition struct {
	Field string `json:"field"`
	Op    string `json:"op"`  // "eq", "neq", "in", etc.
	Val   string `json:"val"` // may reference session vars like "$user_id"
}

Condition is a simple predicate evaluated at request time for row-level filtering.

type FieldDef

type FieldDef struct {
	// Type is one of: string, text, int, int64, float64, bool, uuid, time, json,
	// file. A `file` field (FILES-LINK-S1) stores the id of an uploaded file
	// (the file_id `POST /api/files` returns) as a UUID column carrying a REAL
	// foreign key to the tenant's own files table — the first-class file↔record
	// link. Its on_delete (restrict default | set_null; cascade rejected) governs
	// what happens to the record when the referenced FILE is deleted; deleting
	// the record never deletes the file.
	Type     string   `json:"type"`
	Required bool     `json:"required,omitempty"`
	Unique   bool     `json:"unique,omitempty"`
	Auto     bool     `json:"auto,omitempty"` // for created_at / updated_at
	Enum     []string `json:"enum,omitempty"`
	Relation string   `json:"relation,omitempty"` // name of the related resource
	// OnDelete declares the referential action of THIS field's foreign key when the
	// referenced (parent) row is deleted (MIG-F1-S1): "restrict" | "cascade" |
	// "set_null". Only meaningful on a field that also declares `relation` (the FK
	// lives on the column, like `unique`/`required`). EMPTY DEFAULTS TO RESTRICT —
	// the safe choice: a delete of a still-referenced row is rejected (409) rather
	// than silently orphaning its children (the integrity bug this closes). set_null
	// requires the column to be nullable (not `required`); validated at load.
	OnDelete string `json:"on_delete,omitempty"`
	// OnUpdate declares the referential action of THIS field's foreign key when the
	// referenced (parent) row's KEY changes (MIG-F1-S5): "restrict" | "cascade" |
	// "set_null". Only meaningful with `relation`. EMPTY DEFAULTS TO NO ACTION — which
	// is what Postgres already records for an FK created without ON UPDATE, so adding
	// this key to an existing schema generates NO churn (a re-provision stays a no-op;
	// see refActionForOnUpdate). This is the deliberate asymmetry with on_delete (whose
	// default is RESTRICT): on_delete shipped RESTRICT from the start, so the live FKs
	// already carry it; on_update is introduced over FKs that already exist with NO
	// ACTION, so its default must match them. set_null requires a nullable column.
	OnUpdate string `json:"on_update,omitempty"`
	// References declares the target COLUMN this field's foreign key points at
	// (MIG-F1-S5). EMPTY DEFAULTS TO "id" (the target's implicit primary key — the only
	// behavior before this key, so retrocompat is total). A non-id value must name a
	// column that is UNIQUE on the target (Postgres requires an FK destination to be a
	// PK or unique column/index); validated at load. Only meaningful with `relation`.
	References string `json:"references,omitempty"`
	// RenamedFrom declares the PREVIOUS name of this field's column (MIG-F1-S2): the
	// migration engine emits `ALTER TABLE … RENAME COLUMN <old> TO <new>`
	// (metadata-only, data preserved, indexes/FK/unique follow the column) instead
	// of the converger's drop+add that stranded the data in the old column. The old
	// name must NOT still be a current field (you cannot rename from a name that
	// still exists); validated at load. Once applied the intent is INERT — a
	// re-provision with it present is a no-op (the old column no longer exists).
	RenamedFrom string `json:"renamed_from,omitempty"`
	Default     any    `json:"default,omitempty"`

	// Declarative validation rules (S44). Pointer types distinguish "absent"
	// from a legitimate zero (min: 0, minLength: 0).
	Min       *float64 `json:"min,omitempty"`       // numeric types: value >= Min
	Max       *float64 `json:"max,omitempty"`       // numeric types: value <= Max
	MinLength *int     `json:"minLength,omitempty"` // string/text: rune count >= MinLength
	MaxLength *int     `json:"maxLength,omitempty"` // string/text: rune count <= MaxLength
	Pattern   string   `json:"pattern,omitempty"`   // string/text: RE2 regex, len <= MaxPatternLength
	Format    string   `json:"format,omitempty"`    // string/text: email | uuid | url | date

	// Accept (FILES-1) — file fields only — is the per-FIELD upload policy: the
	// content types this field will attach. Entries are matched against the
	// file's STORED (magic-byte-sniffed at upload, never client-declared)
	// content type: a bare family name ("image", "audio", "video", "text")
	// matches the whole family (image/*…), "pdf" is a convenience alias for
	// application/pdf, and an entry containing "/" matches that exact type
	// ("application/zip"). JSON accepts a single string or an array. Enforced
	// at ATTACH time (create/update on REST, GraphQL and batch — the upload
	// endpoint is field-agnostic, so the field's policy applies when a record
	// references the file): a violating file_id is a 422 `file_policy` naming
	// what the field accepts. The instance-wide env knobs
	// (APPXIMO_FILES_MAX_BYTES / _ALLOWED_EXT) remain the outer bound at
	// upload.
	Accept StringList `json:"accept,omitempty"`
	// MaxBytes (FILES-1) — file fields only — caps the size of a file this
	// field will attach, in bytes (checked against the stored size at attach
	// time, same 422 file_policy). Must be > 0 when declared.
	MaxBytes int64 `json:"max_bytes,omitempty"`

	// StateMachine (G5) declares the allowed lifecycle transitions of a string
	// status field: which states a row may be CREATED in (Initial) and which moves
	// are permitted between states (Transitions). The engine forces it on create
	// (the initial state must be valid) and on update (a state may only move along a
	// declared transition; a state with no outgoing transitions is terminal /
	// immutable). A field without StateMachine is a free string (unchanged). Only
	// string/text fields; coherent with `enum` if both are declared.
	StateMachine *StateMachine `json:"state_machine,omitempty"`
}

FieldDef describes one field within a resource.

The declarative validation keys (min/max, minLength/maxLength, pattern, format) are ALL optional — a schema that declares none of them behaves exactly as before. They are compiled into a ResourceValidator at schema load (see rules.go); the request path never compiles anything.

func (*FieldDef) FileAcceptMatches

func (fd *FieldDef) FileAcceptMatches(contentType string) bool

FileAcceptMatches reports whether a stored (sniffed) content type satisfies this field's accept list. An empty list accepts everything. An EMPTY stored content type fails a non-empty list closed: a file whose type could not be determined is not evidence it is an image.

func (*FieldDef) HasFilePolicy

func (fd *FieldDef) HasFilePolicy() bool

HasFilePolicy reports whether this file field declares a per-field attach policy (FILES-1) — the gate the write paths check before paying any lookup.

func (FieldDef) ReferencedColumn added in v0.1.5

func (f FieldDef) ReferencedColumn() string

ReferencedColumn resolves the target column this field's foreign key points at: `references` when declared (MIG-F1-S5), else the implicit "id". It is the SINGLE source of that rule — the relation subroute, the ?include= embed compiler (REST and GraphQL share it) and anything else that follows an FK must call this instead of re-deriving the default, so the resolutions can never diverge (PUBLIC-SURFACE-S1: the embed compiler had its own hardcoded "id" and returned null for every FK declaring a non-id `references`, while the subroute followed the FK correctly).

type FieldRuleError

type FieldRuleError struct {
	Field   string `json:"field"`
	Rule    string `json:"rule"`
	Message string `json:"message"`
}

FieldRuleError is one declarative-validation violation, in the exact shape the 422 response body carries inside "fields".

type ForeignKeyDef

type ForeignKeyDef struct {
	Columns    []string `json:"columns"`             // local columns (this resource)
	Target     string   `json:"target"`              // referenced resource
	RefColumns []string `json:"ref_columns"`         // referenced columns on Target (same count as Columns)
	OnDelete   string   `json:"on_delete,omitempty"` // restrict (default) | cascade | set_null
	OnUpdate   string   `json:"on_update,omitempty"` // restrict | cascade | set_null; unset → NO ACTION
}

ForeignKeyDef is one resource-level COMPOSITE foreign key (MIG-F1-S5): a set of local Columns referencing the same number of RefColumns on the Target resource, which must together form the target's PRIMARY KEY or a UNIQUE constraint/index (Postgres requires it; validated at load). OnDelete defaults to RESTRICT (safe — like the field-level relation) and OnUpdate defaults to NO ACTION (no-churn). A single-column FK is the field-level `relation`, not this block.

type HookConfig

type HookConfig struct {
	Type          string `json:"type"`                      // "js" | "webhook" | "wasm"
	Script        string `json:"script,omitempty"`          // JS source for type=js
	URL           string `json:"url,omitempty"`             // endpoint for type=webhook
	HMACSecretEnv string `json:"hmac_secret_env,omitempty"` // env var holding the HMAC secret
	WasmModule    string `json:"wasm_module,omitempty"`     // for type=wasm: name of the pre-loaded module
	WasmFn        string `json:"wasm_fn,omitempty"`         // for type=wasm: function to call (default "transform")
	Timeout       string `json:"timeout,omitempty"`         // execution budget, e.g. "500ms" (default "500ms")
}

HookConfig defines a lifecycle hook on a resource (before_create, after_create, etc.).

type IndexDef

type IndexDef struct {
	Fields []string `json:"fields"`
	Unique bool     `json:"unique,omitempty"`

	// Method is the Postgres access method (LIBRARY-GAPS-S1): "btree" (the
	// default, and what every pre-S1 index used) or "gin". EMPTY MEANS BTREE, so
	// adding this key to an existing schema generates zero churn — the introspector
	// reads pg_am.amname back, and an unchanged index diffs identical.
	//
	// "gin" is the index that makes jsonb containment (`@>`) an index lookup
	// instead of a sequential scan; it is accepted ONLY over `jsonb` columns
	// (validated at load, never a runtime surprise) and never with unique (GIN
	// cannot enforce uniqueness).
	Method string `json:"method,omitempty"`

	// Opclass is the operator class applied to EVERY listed column, e.g.
	// "jsonb_path_ops" for a GIN index that only ever answers `@>` (smaller and
	// faster than the default jsonb_ops, at the cost of key-existence `?`
	// queries). Only valid with an explicit method, and only a value from the
	// method's closed allowlist (validIndexOpclasses) — it is rendered into DDL,
	// so it is never free-form text. Invisible to the diff (the introspector
	// cannot read an opclass back from the index key list), which is exactly why
	// declaring one causes no migration churn.
	Opclass string `json:"opclass,omitempty"`
}

IndexDef specifies an index on one or more fields (a composite index when more than one). Applied at tenant migration as CREATE [UNIQUE] INDEX IF NOT EXISTS over the listed columns (BUGS-V1 — previously parsed but not applied).

type RBACPolicy

type RBACPolicy struct {
	Roles map[string]RolePolicy `json:"roles"`

	// Public declares the ANONYMOUS surface (PUBLIC-SURFACE-S1, ADR-026): the
	// resources an UNAUTHENTICATED request may READ, each with its own row
	// condition and field allowlist — a blog's published articles, a catalogue,
	// a landing — with no Go and no token. It reuses the per-resource
	// permission shape (`{ "articulos": { "actions": ["read"], "conditions":
	// {…}, "fields": […] } }`) and compiles into the ONE existing evaluator as
	// the reserved role rbac.PublicRoleName, so every enforcement surface
	// (REST, GraphQL, aggregates, embeds, SSE, files) is the same code path an
	// authenticated role uses — never a second implementation.
	//
	// Constraints, validated at load (deny-by-default stays intact):
	//   - actions must be exactly ["read"] — the anonymous surface is
	//     read-only in v1 (an anonymous write is a spam/abuse surface that
	//     needs its own design; ADR-026 records the reasoning).
	//   - conditions.val must be a LITERAL ("published") — $user_id /
	//     $external_client_id name an identity an anonymous request does not
	//     have, so they are load errors here.
	//   - the built-in "files" store is grantable (actions-only, like any
	//     role) — the public-image pattern.
	//   - absent block ⇒ NOTHING anonymous, exactly today's behavior. The key
	//     is NEW, and unknown keys have always rejected the schema, so no
	//     existing schema can change meaning by upgrading the engine.
	//
	// Anonymous requests are additionally throttled by the public-route rate
	// limiter (APPXIMO_PUBLIC_ROUTE_RPS) and NEVER served from or stored in
	// the response cache.
	Public map[string]ResourcePermission `json:"public,omitempty"`
}

RBACPolicy holds all role definitions for a resource set.

type RelationDef

type RelationDef struct {
	Type     string `json:"type"`                // has_many | belongs_to | many_to_many
	Target   string `json:"target"`              // related resource name
	FK       string `json:"fk"`                  // foreign-key column (see Type for which table)
	Through  string `json:"through,omitempty"`   // junction table (many_to_many only)
	TargetFK string `json:"target_fk,omitempty"` // target's FK column in Through (many_to_many only)
	Limit    int    `json:"limit,omitempty"`     // top-N children per parent (0 → DefaultEmbedLimit)
}

RelationDef declares one relation between resources (RELATIONS-V1, ADR-019), served nested in a single round-trip via json_agg + LATERAL when a client opts in with ?include=. Declaration is EXPLICIT (no FK-catalog inference): the relation compiles once at boot from the shared schema, never per request.

has_many     — parent → many children: FK lives on the TARGET (child) table,
               matched against the parent's id (child.<FK> = parent.id).
belongs_to   — child → its parent: FK lives on THIS (source) table, matched
               against the target's id (target.id = source.<FK>).
many_to_many — both sides via a junction table: Through holds FK (this side's
               id) and TargetFK (the target's id).

type ResourcePermission

type ResourcePermission struct {
	Actions          []string   `json:"actions"`
	Conditions       *Condition `json:"conditions,omitempty"`
	ConditionActions []string   `json:"condition_actions,omitempty"` // actions the condition gates (empty = all)
	Fields           []string   `json:"fields,omitempty"`            // per-resource field allowlist
}

ResourcePermission is one role's grant on one resource (G2). Mirrors rbac.ResourcePermission so the schema→rbac.Policy JSON round-trip is lossless.

type ResourceSchema

type ResourceSchema struct {
	Fields  map[string]FieldDef   `json:"fields"`
	Hooks   map[string]HookConfig `json:"hooks,omitempty"`
	Indexes []IndexDef            `json:"indexes,omitempty"`

	// ForeignKeys declares COMPOSITE (multi-column) foreign keys at the resource
	// level (MIG-F1-S5). A single-column FK is the field-level `relation` (which is
	// inherently 1 column = 1 column → target.id, optionally → a unique non-id column
	// via `references`); a composite FK references MULTIPLE columns of the target's
	// composite PK/unique, which does not fit the field-level model, so it gets an
	// explicit block. Each entry is one constraint over (columns) → target(ref_columns)
	// with its own on_delete/on_update. A resource that omits this key behaves exactly
	// as before. See ForeignKeyDef.
	ForeignKeys []ForeignKeyDef `json:"foreign_keys,omitempty"`

	// RenamedFrom declares the PREVIOUS name of this resource's table (MIG-F1-S2):
	// the migration engine emits `ALTER TABLE <old> RENAME TO <new>` (metadata-only,
	// data + indexes + constraints preserved) instead of the converger's drop+add
	// that stranded the data. The old name must NOT still be a current resource
	// (you cannot rename from a name that still exists); validated at load. Once the
	// rename is applied the intent is INERT — re-provisioning with it still present
	// is a no-op (the old table no longer exists), so it is safe to leave in place.
	RenamedFrom string `json:"renamed_from,omitempty"`

	// Relations is the opt-in set of declarative relations (RELATIONS-V1,
	// ADR-019) keyed by the embed name exposed to clients (the key used in
	// ?include=<name> and the GraphQL nested field). A resource that omits this
	// key serves exactly as before and pays zero overhead on the read path —
	// relations are served ONLY when explicitly requested via ?include=.
	Relations map[string]RelationDef `json:"relations,omitempty"`

	// Events is the opt-in list of write actions that emit a transactional
	// outbox event (CRUD-EMIT-V1). Valid values: "create", "update", "delete"
	// (present-tense, matching the RBAC action vocabulary). For a declared
	// action the engine writes a row to public.outbox IN THE SAME TRANSACTION as
	// the CRUD write, with topic "{resource}.{created|updated|deleted}". A
	// resource that omits this key emits nothing and pays zero overhead. See
	// EmitActions / EmitTopic.
	Events []string `json:"events,omitempty"`
}

ResourceSchema defines a single entity (table) with its fields, hooks, and indexes.

func (ResourceSchema) EmitsOn

func (r ResourceSchema) EmitsOn(action string) bool

EmitsOn reports whether the resource opted into emitting an event for action ("create" | "update" | "delete"). Computed from ResourceSchema.Events.

type ResourceValidator

type ResourceValidator struct {
	// contains filtered or unexported fields
}

ResourceValidator holds the precompiled validation for one resource: the regexes are compiled, the enum sets built, and the required list resolved ONCE at schema load. The request path only executes these closures.

func CompileRules

func CompileRules(res *ResourceSchema) *ResourceValidator

CompileRules builds the ResourceValidator for one resource. It never panics: an invalid pattern (only reachable when schema.Validate was skipped, since Validate rejects it at load) compiles to a fail-closed rule that rejects every value for that field — never a silently dropped rule.

func (*ResourceValidator) ApplyDefaults

func (rv *ResourceValidator) ApplyDefaults(body map[string]any)

ApplyDefaults fills, in place, any field that declares a default and is ABSENT from body (a present key — even an explicit null — is left as the caller set it, matching SQL DEFAULT, which applies only when a column is omitted). Called on CREATE only, BEFORE required-field validation, so a required field with a default is satisfied by the default while a required field without one still 422s. A resource with no defaults pays a single length check (the create gate).

func (*ResourceValidator) ValidateInitialStates

func (rv *ResourceValidator) ValidateInitialStates(body map[string]any) []FieldRuleError

ValidateInitialStates checks, for a CREATE body, that every present state-machine field holds one of its INITIAL states — a row may not be created already advanced in its lifecycle. Call it on create only, after ValidateWrite (which already rejected unknown states). No-op for a resource with no state machines.

func (*ResourceValidator) ValidateWrite

func (rv *ResourceValidator) ValidateWrite(body map[string]any, requireAll bool) []FieldRuleError

ValidateWrite checks a decoded JSON body against the precompiled rules and returns ALL violations (never just the first). requireAll is true for POST (create) and PUT (full replace): every required non-auto field must be present and non-null. PATCH passes false — only the fields present in the body are validated. A null value is skipped by the per-field rules (null semantics belong to required / the update path).

type RolePolicy

type RolePolicy struct {
	Resources  json.RawMessage `json:"resources,omitempty"`
	Actions    []string        `json:"actions,omitempty"`
	Conditions *Condition      `json:"conditions,omitempty"`
	Fields     []string        `json:"fields,omitempty"` // field-level allowlist (read-only roles)

	// Permissions is the per-resource form (G2): resource name → its grant. Empty for
	// a legacy role; omitempty keeps the marshalled legacy policy byte-identical so the
	// schema→rbac.Policy round-trip is unchanged for every existing schema.
	Permissions map[string]ResourcePermission `json:"permissions,omitempty"`

	// Routes grants access to CUSTOM ROUTES — endpoints a Go backend registers with
	// (*App).Register, whose first /api/ segment the engine authorizes as a VIRTUAL
	// resource (LIBRARY-GAPS-S1). It is keyed by that segment, with its own actions:
	//
	//	"routes": { "checkout": { "actions": ["create"] } }   → POST /api/checkout
	//
	// It is ORTHOGONAL to resources/permissions — a role may declare both, because
	// they govern different namespaces (real tables vs registered endpoints). That
	// is the whole point: before this key, a role using per-resource `permissions`
	// could not reach ANY custom route (every permissions key is checked against a
	// real resource), so "owner-scoped end users + a custom action endpoint" — a
	// customer with their own orders AND a checkout — was inexpressible.
	//
	// SEMANTICS, deliberately narrow:
	//   - AUTHORITATIVE: for a segment listed here, this entry decides. It can only
	//     narrow a wildcard role, never widen one — a segment NOT listed falls
	//     through to the role's normal resources/permissions evaluation, so
	//     deny-by-default is untouched.
	//   - NO conditions, NO field allowlist. A virtual segment has no rows and no
	//     columns; a row filter would be injected into SQL for a table that does not
	//     exist. Declaring either is a LOAD error, never a silent no-op.
	//   - Validated at BOOT against the REGISTERED routes: a grant for a segment no
	//     route serves (or an action no registered method provides) fails the boot
	//     with a clear message, instead of a confusing 403 at request time.
	// See docs/adr/ADR-021-custom-route-authorization.md.
	Routes map[string]RouteGrant `json:"routes,omitempty"`
}

RolePolicy defines what a role can do. Resources is json.RawMessage because it can be the string "*" or an array of resource names.

A role is expressed in ONE of two mutually-exclusive forms (G2):

  • Role-global (legacy): Resources + Actions + (optional) Conditions/Fields — the single condition/allowlist applies to EVERY listed resource (unchanged).
  • Per-resource: a Permissions map where each resource carries its OWN actions, condition and field allowlist (workspace/participation/owner scoping). When present it is the sole source of truth (deny-by-default for absent resources).

Declaring both forms on one role is rejected at validation.

type RouteGrant

type RouteGrant struct {
	Actions []string `json:"actions"`
}

RouteGrant is one role's grant on ONE custom-route segment (LIBRARY-GAPS-S1). Actions are the same vocabulary as everywhere else (read/create/update/delete/*), mapped from the HTTP method the middleware already derives: GET→read, POST→create, PUT|PATCH→update, DELETE→delete. There is deliberately no conditions/fields key — see RolePolicy.Routes.

type StateMachine

type StateMachine struct {
	Initial     []string            `json:"initial"`
	Transitions map[string][]string `json:"transitions"`
}

StateMachine is the declarative lifecycle of a status field (G5). Initial is the set of states a row may be created in; Transitions maps each state to the states it may move to (an absent key or empty list ⇒ terminal). `initial` accepts a single string or an array in JSON; it is always a slice after parsing.

func (*StateMachine) IsInitial

func (sm *StateMachine) IsInitial(s string) bool

IsInitial reports whether s is a state a row may be created in.

func (*StateMachine) KnownStates

func (sm *StateMachine) KnownStates() map[string]bool

KnownStates returns the set of every state the machine references (initial states, transition sources, and transition targets) — the universe of valid state values.

func (*StateMachine) OriginsOf

func (sm *StateMachine) OriginsOf(target string) []string

OriginsOf returns the states from which a transition to target is declared (the reverse of Transitions), sorted for deterministic SQL. Empty ⇒ no state may transition INTO target (it is only reachable as an initial state, if at all).

func (*StateMachine) UnmarshalJSON

func (sm *StateMachine) UnmarshalJSON(data []byte) error

UnmarshalJSON accepts `initial` as either a string ("pending") or an array (["pending","draft"]), normalizing to a slice — the rest is plain.

type StringList

type StringList []string

StringList is a []string that additionally accepts a single JSON string ("image" ⇒ ["image"]) — the same author-friendly flexibility as state_machine's `initial`.

func (*StringList) UnmarshalJSON

func (sl *StringList) UnmarshalJSON(data []byte) error

UnmarshalJSON accepts a string or an array of strings.

type StructuredError

type StructuredError struct {
	Path     string   `json:"path"`               // dotted location, e.g. resources.posts.fields.author_id.references
	Rule     string   `json:"rule"`               // machine-readable category, e.g. invalid_type
	Message  string   `json:"message"`            // human-readable explanation
	Expected []string `json:"expected,omitempty"` // allowed values, when a closed set
	Got      string   `json:"got,omitempty"`      // the offending value, when applicable
	Fix      string   `json:"fix,omitempty"`      // how to correct it
	Source   string   `json:"source"`             // "metaschema" (structural) | "semantic" (cross-reference)
}

StructuredError is one validation failure in a form an LLM can correct from.

type ValidationError

type ValidationError struct {
	Field   string
	Message string

	// Structured metadata for the LLM-friendly report (AI-F0-S2). All optional —
	// an error that predates the enrichment leaves them empty and the report derives
	// a fallback Rule from the Field path. They are NOT part of Error(), so the human
	// (interactive) output is byte-unchanged.
	Rule     string   `json:"rule,omitempty"`     // machine-readable category, e.g. "invalid_type"
	Fix      string   `json:"fix,omitempty"`      // how to correct it (an LLM can apply this)
	Expected []string `json:"expected,omitempty"` // the allowed values, when a closed set
	Got      string   `json:"got,omitempty"`      // the offending value, when applicable
}

func CheckUnknownKeys

func CheckUnknownKeys(raw json.RawMessage) []ValidationError

CheckUnknownKeys validates the RAW schema JSON against the known key set of every level of the schema contract. Go's json.Unmarshal silently drops keys the structs don't declare, which turns typos into silent no-ops — fatal for a product whose entire contract IS the schema (a user writing "webhooks" instead of "hooks" must get an error, not a quietly dead feature).

It must be called with the raw bytes BEFORE/alongside unmarshalling (after parsing, unknown keys are already gone). Returns one error per unknown key, each listing the valid keys for that level. WorkflowStep.Config is the one deliberately free-form map (step-specific configuration).

func Validate

func Validate(s *APISchema) []ValidationError

func ValidateAgainstMetaSchema

func ValidateAgainstMetaSchema(raw []byte) []ValidationError

ValidateAgainstMetaSchema validates raw schema bytes against the embedded JSON Schema meta-schema and returns one ValidationError per STRUCTURAL violation (Field is the JSON-pointer instance location, Message the reason). It is ADDITIVE to Validate, never a replacement: it catches the structural errors JSON Schema can express (early, precise, engine-free), while Validate stays the semantic authority. Returns nil when the document is structurally valid.

func Warnings

func Warnings(s *APISchema) []ValidationError

Warnings returns the non-blocking findings for a schema. A nil/empty result means nothing suspicious was found — never that the schema is correct.

func (ValidationError) Error

func (e ValidationError) Error() string

type ValidationReport

type ValidationReport struct {
	Valid  bool              `json:"valid"`
	Errors []StructuredError `json:"errors"`
	// Warnings are findings that do NOT make the schema invalid but almost always
	// mean it will not do what its author intended (SCHEMA-5). They are reported in
	// the same shape as errors so the AI correction loop can act on them with the
	// same code path, and `valid` stays true — a warning never blocks a deploy.
	//
	// ALWAYS EMITTED, even when empty — deliberately not omitempty. With the key
	// absent, "this schema has no warnings" and "this binary has no warnings
	// feature" are the same JSON, so a caller cannot tell a clean bill of health
	// from an old engine. A third-party agent reported exactly that: it could not
	// confirm zero warnings as a POSITIVE signal and pre-empted the known rules by
	// hand instead. `errors` has always been emitted unconditionally; this makes
	// the two halves of the report symmetric.
	Warnings []StructuredError `json:"warnings"`
}

ValidationReport is the top-level result for a candidate schema.

func ValidateReport

func ValidateReport(raw []byte) ValidationReport

ValidateReport runs BOTH validators over raw schema bytes and returns a unified, LLM-friendly report. The meta-schema provides the structural errors (types, enums, patterns, unknown/missing keys, RBAC form); the Go validator provides the semantic cross-reference errors (existence, uniqueness, type compatibility, coherence). A path that already has a structural error suppresses the semantic error at the SAME path (they are the same problem reported by two layers), so the report is deduplicated without losing either layer's unique findings.

type WorkflowSchema

type WorkflowSchema struct {
	Trigger WorkflowTrigger `json:"trigger"`
	Steps   []WorkflowStep  `json:"steps,omitempty"`
}

WorkflowSchema is one named workflow: a trigger plus an ordered list of steps.

type WorkflowStep

type WorkflowStep struct {
	Name   string         `json:"name"`
	Type   string         `json:"type"`             // "hook" | "webhook" | "wasm" | "branch"
	Ref    string         `json:"ref,omitempty"`    // hook/module/url reference
	Config map[string]any `json:"config,omitempty"` // step-specific configuration
	Next   string         `json:"next,omitempty"`   // name of the next step (or branch target)
}

WorkflowStep is a single node in a workflow pipeline.

type WorkflowTrigger

type WorkflowTrigger struct {
	Type     string `json:"type"`               // "event" | "cron" | "http"
	Event    string `json:"event,omitempty"`    // e.g. "after_create" (with Resource)
	Resource string `json:"resource,omitempty"` // resource the event applies to
	Cron     string `json:"cron,omitempty"`     // cron expression for type=cron
	Path     string `json:"path,omitempty"`     // route for type=http
}

WorkflowTrigger describes what starts a workflow.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL