Documentation
¶
Index ¶
- Constants
- Variables
- func AsFloat64(v any) (float64, bool)
- func EmitTopic(resource, action string) string
- func Explain(s *APISchema, lang string) string
- func GraphQLPascal(s string) string
- func GraphQLSingular(name string) string
- func GraphQLTypeName(resourceName string) string
- func IsIntegral(v any) bool
- func MetaSchemaJSON() []byte
- func RelationSubroute(fieldName string) string
- type APISchema
- type AutoValue
- type Condition
- type FieldDef
- type FieldRuleError
- type ForeignKeyDef
- type GovernedOp
- type HookConfig
- type ImportConfig
- type IndexDef
- type RBACPolicy
- type RelationDef
- type ResourcePermission
- type ResourceSchema
- func (r *ResourceSchema) AutoRefreshColumns() []string
- func (r ResourceSchema) EmitsOn(action string) bool
- func (r *ResourceSchema) GovernedWriteFields() []string
- func (r *ResourceSchema) ImportDeclaredFields() []string
- func (r *ResourceSchema) ImportableOnCreate(role, field string) bool
- func (r *ResourceSchema) IsGovernedWriteField(name string) bool
- type ResourceValidator
- type RolePolicy
- type RouteGrant
- type StateMachine
- type StringList
- type StructuredError
- type ValidationError
- type ValidationReport
- type WorkflowSchema
- type WorkflowStep
- type WorkflowTrigger
Constants ¶
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).
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.
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.
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.
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.
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 ¶
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
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 ¶
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
Explain renders the schema as plain-language prose. lang: "en" | "es" (anything else falls back to "en").
func GraphQLPascal ¶ added in v0.1.9
GraphQLPascal upper-cases each `_`/`-`-separated segment and joins them.
func GraphQLSingular ¶ added in v0.1.9
GraphQLSingular strips a trailing plural so a resource `guides` yields the type stem `guide`. It is intentionally simple (the engine's naming, not a linguistics library); its ONLY contract is that pkg/graphql and the validator compute the same value.
func GraphQLTypeName ¶ added in v0.1.9
GraphQLTypeName is the type stem a resource contributes to the GraphQL schema (e.g. `categorias` and `categoria` both → `Categoria`, which is why two such resources collide). Every generated GraphQL type name is built from this.
func IsIntegral ¶ added in v0.1.7
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).
func RelationSubroute ¶ added in v0.1.9
RelationSubroute is the URL segment a relation field's subroute serves under: the field name minus a trailing "_id" (`customer_id` → `customer`; a name without the suffix is used as-is).
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 ¶
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 ¶
type AutoValue ¶ added in v0.1.9
type AutoValue string
AutoValue is the declared engine-management role of a timestamp field — the value of the `auto` key (SILENT-CORRUPTION-S1).
JSON forms accepted:
"auto": true → AutoLegacy — the historical contract, byte-compatible:
the column is provisioned TIMESTAMPTZ DEFAULT now()
(set once at insert) and, ONLY when the field is
literally named `updated_at`, additionally refreshed to
now() on every update. Any other name behaves as a
creation timestamp.
"auto": "create" → AutoCreate — an explicit creation timestamp: set once
at insert, never refreshed, regardless of the field's
name. `creado_en`, `placed_at`, `fecha` all work.
"auto": "update" → AutoUpdate — an explicit modification timestamp: set at
insert AND refreshed to now() on every engine update
(REST PUT/PATCH, GraphQL update, batch transaction,
Ctx.Update), regardless of the field's name.
`modificado_en` finally means what it says.
"auto": false → off (same as omitting the key).
The string forms exist because binding the refresh to the literal English name `updated_at` silently froze every non-English modification timestamp at its creation value forever — a validator-clean schema whose data was wrong (ENG-45). The declared role, not the field's name, is now the source of truth; the legacy boolean keeps its name-magic for backward compatibility and raises a load warning when its name suggests update intent.
All three enabled forms share the auto contract: exempt from `required`, no `default`, excluded from create/update inputs (writes answer 422 read_only on update), type must be `time` (validated at load — the column is TIMESTAMPTZ regardless of the declaration, so any other declared type would silently diverge from the database).
const ( // AutoOff — the field is not engine-managed (the zero value). AutoOff AutoValue = "" // AutoLegacy — JSON `true`: creation-timestamp semantics plus the // documented literal-`updated_at` refresh magic. AutoLegacy AutoValue = "legacy" // AutoCreate — JSON `"create"`: explicit creation timestamp, any name. AutoCreate AutoValue = "create" // AutoUpdate — JSON `"update"`: explicit modification timestamp, any name. AutoUpdate AutoValue = "update" )
func (AutoValue) Enabled ¶ added in v0.1.9
Enabled reports whether the field is engine-managed at all (any of the three enabled forms). Every consumer that used the old boolean semantics (`fd.Auto` as bool) asks this.
func (AutoValue) MarshalJSON ¶ added in v0.1.9
MarshalJSON round-trips faithfully: the legacy form marshals back to `true` (the editor's round-trip contract — a schema authored with booleans is re-exported with booleans), the explicit roles as their strings.
func (AutoValue) RefreshesOnUpdate ¶ added in v0.1.9
RefreshesOnUpdate reports whether a field declared with this AutoValue and this name is refreshed to now() by every engine update. The single decision point: an explicit "update" role always refreshes; the legacy boolean refreshes only the literal `updated_at` (its documented magic); "create" never refreshes, even on a field named updated_at (the author explicitly opted out).
func (*AutoValue) UnmarshalJSON ¶ added in v0.1.9
UnmarshalJSON accepts the boolean legacy form and the explicit string roles. An unrecognized string is preserved verbatim so the validator can reject it with a named, actionable error (a raw json error here would lose the path).
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 declares an engine-managed timestamp. Accepts `true` (legacy:
// creation timestamp + refresh-on-update ONLY for the literal name
// `updated_at`), or the explicit, name-independent roles `"create"` /
// `"update"` (SILENT-CORRUPTION-S1) — see AutoValue in auto.go.
Auto AutoValue `json:"auto,omitempty"`
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) EffectiveAutoRole ¶ added in v0.1.9
EffectiveAutoRole names the role an enabled auto field actually plays — "update" when the engine refreshes it, "create" otherwise, "" when not auto. Consumed by the OpenAPI generator (`x-appximo-auto`) so generic tools read the truth instead of guessing from English field names.
func (*FieldDef) FileAcceptMatches ¶
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 ¶
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
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".
func GovernedFieldViolations ¶ added in v0.1.9
func GovernedFieldViolations(r *ResourceSchema, body map[string]any, op GovernedOp, role string) []FieldRuleError
GovernedFieldViolations is the ONE implementation of the governed-field write rule. It returns a 422-shaped read_only violation for every governed field present in the body that the operation does not permit, sorted by field name (deterministic responses, the ENG-16 class). An empty result means the body carries no forbidden governed field.
The update-side messages are byte-compatible with what CollectUpdate answered before this file existed (a pinned public contract); the create-side messages additionally say how to make the write legal, because there IS a legal way (ADR-024: name the field, say what to do).
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 GovernedOp ¶ added in v0.1.9
type GovernedOp int
GovernedOp selects which write door's contract GovernedFieldViolations enforces.
const ( // GovernedCreate — a create body: governed fields are rejected unless the // resource's `import` declaration grants them to the caller's role. GovernedCreate GovernedOp = iota // GovernedUpdate — an update body: governed fields are ALWAYS rejected // (import is a create-time concept; an existing row's engine-managed // values are immutable through every door). GovernedUpdate )
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 ImportConfig ¶ added in v0.1.9
type ImportConfig struct {
// Roles is the closed list of RBAC roles granted import. Required,
// non-empty; every entry must be a role the schema's rbac block declares
// (a typo is a load error, never a silently dead grant — the ENG-27 mold).
// There is no wildcard: an auditor reads exactly who may import.
Roles []string `json:"roles"`
// Fields optionally narrows WHICH governed fields the grant covers, e.g.
// ["id"] for client-generated ids without opening timestamp forgery.
// Each entry must be "id" or an auto field of the resource. Absent = the
// full governed set.
Fields []string `json:"fields,omitempty"`
}
ImportConfig is the resource-level `import` declaration: which roles may supply the engine-governed fields (`id` + every `auto` field) when CREATING rows, and optionally which subset of those fields.
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"`
// Import declares which roles may supply the engine-governed fields (the
// implicit `id` + every `auto` timestamp) when CREATING rows — the data-
// import / fixture-restore contract (WRITE-ASYMMETRY-S1). Absent, the
// governed fields are rejected on create at every door, exactly as they
// always were on update. See ImportConfig / GovernedFieldViolations.
Import *ImportConfig `json:"import,omitempty"`
}
ResourceSchema defines a single entity (table) with its fields, hooks, and indexes.
func (*ResourceSchema) AutoRefreshColumns ¶ added in v0.1.9
func (r *ResourceSchema) AutoRefreshColumns() []string
AutoRefreshColumns returns, sorted, the columns of this resource the engine forces to now() on every update — the SINGLE source the REST update core, the batch transaction, and Ctx.Update all consume, so no update path can diverge on which timestamps refresh (SILENT-CORRUPTION-S1: two of the three used to hardcode the literal `updated_at` and the third refreshed nothing).
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.
func (*ResourceSchema) GovernedWriteFields ¶ added in v0.1.9
func (r *ResourceSchema) GovernedWriteFields() []string
GovernedWriteFields returns, sorted, the fields of this resource whose values the engine owns on the write path: the implicit "id" plus every field with an enabled `auto` role.
func (*ResourceSchema) ImportDeclaredFields ¶ added in v0.1.9
func (r *ResourceSchema) ImportDeclaredFields() []string
ImportDeclaredFields returns, sorted, the governed fields this resource's `import` declaration actually covers — the declared subset when one is given, else the full governed set. Empty when the resource declares no import. Consumed by the GraphQL input-type builder (which fields exist on the create input at all) and the OpenAPI generator (`x-appximo-import`), so both surfaces publish exactly what the one predicate enforces.
func (*ResourceSchema) ImportableOnCreate ¶ added in v0.1.9
func (r *ResourceSchema) ImportableOnCreate(role, field string) bool
ImportableOnCreate reports whether `role` may supply governed field `field` on create under this resource's `import` declaration. False when there is no declaration, when the role is not granted, or when a declared fields subset excludes the field.
func (*ResourceSchema) IsGovernedWriteField ¶ added in v0.1.9
func (r *ResourceSchema) IsGovernedWriteField(name string) bool
IsGovernedWriteField reports whether a write-body key is engine-governed — the implicit primary key or an auto timestamp. The predicate every door's own key loop uses to skip keys the single source already judged.
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.