The identity card
A TypeScript class cannot be serialized directly. Its card is a JSON document that describes the schema and lets a consumer rebuild a validation class.
The two are duals. ~standard hands the live validator to
a library in the same process; the card is the serializable description of the same
declaration, for exchanges across processes or languages.
import { Bundle, Card } from '@fougere/schema';
const descriptor = Card.fromSchema(Post).descriptor; // schema to JSON document
const Rebuilt = Card.fromDescriptor(descriptor).toSchema(); // document to living schema
Card.fromSchema produces the canonical document used by adapters. Card.toSchema
rebuilds the schema at the consumer, which can then validate data locally.
State the shape of a row and the rebuilt schema is a class in the full sense — one name
carrying the validator and the type of what it validates, exactly like class Post extends entity({…}) {}. This is what fougere sync
writes:
class Post extends Card.fromDescriptor<{ id: string; title: string }>(descriptor).toSchema() {}
The structure
The card is a valid JSON Schema document. The shape axis is that vocabulary, so its
keywords sit at the field's top level; the three other axes live under the x-fougere
extension key.
{
"title": "Post",
"type": "object",
"properties": {
"id": {
"type": "string",
"x-fougere": {
"role": { "primary": true },
"lifecycle": { "create": { "generate": "cuid2" }, "update": "forbidden" }
}
},
"title": { "type": "string", "minLength": 3, "maxLength": 120 },
"status": { "type": "string", "enum": ["draft", "published"] },
"publishedAt": {
"type": ["string", "null"],
"format": "date-time",
"x-fougere": { "lifecycle": { "update": "forbidden" } }
},
"secret": { "type": "string", "x-fougere": { "boundary": { "out": "closed" } } }
},
"required": ["title", "status"],
"x-fougere-version": 1,
"x-fougere-vendor": "fougere"
}
A nullable field folds into the union type: ["T", "null"] — nullability is shape, hence
plain JSON Schema. An embedded object (json(Entity)) carries its nested properties and
required, themselves shape-only.
A view says what it is a view of
The card of a view carries one extra key, absent from a declaration that derives from nothing:
"x-fougere-derived": {
"from": "Post",
"nameOf": { "id": "id", "title": "title", "body": null, "status": null }
}
from names the origin and nameOf says what the cut left, keyed by the origin's field
names — a dropped field is present and null. title already carried the origin's name, and
carried it alone: two views of one entity described identically, and nothing in the card
said which served which fields. A reader can now answer a Post without body, and
fougere sync can write the derivation rather than a flattened shape.
The three axes under x-fougere
The values under x-fougere are names and JSON structures. No separate descriptor is
required.
| key | values | what it states |
|---|---|---|
role.primary | true | identity — the system owns it |
role.unique | [["slug"], ["listId","docId"]] | the unique constraints this field is a member of — one member list each, members named. A lone field is the degenerate case: a constraint of one. The DDL emits them |
role.index | [["title"], ["slug","title"]] | the indexes this field is a member of, listed like unique — the DDL emits them |
role.relation | { to, kind: 'one'|'many', onDelete? } | to is a name, not an inlined schema |
lifecycle.create | { value } | 'now' | { generate } | 'optional' | who writes at creation, and when |
lifecycle.update | 'now' | 'forbidden' | who writes on update |
boundary.in | 'closed' | { decode } | read-only, or a named inbound conversion |
boundary.out | 'closed' | { encode } | write-only, or a named outbound conversion |
An absent key means open: no lifecycle.create means the caller must supply the value; no
boundary means identity in both directions.
Only an explicitly declared boundary is emitted. Behaviour inferred from the shape, such
as decoding a format: date-time field into a Date, is derived again during
reconstruction.
role.unique always names its members in the document, including a single-field constraint
([["slug"]]). In memory a lone unique() does not yet know its key; Card.fromSchema resolves
it from the object passed to entity({...}). A compound constraint such as
(listId, docId) appears in full on each member.
required semantics
In a Fougere card, required has a narrower meaning than in JSON Schema alone.
required lists the names a caller must supply at creation — those no lifecycle.create
rule answers the absence of, and that are not a many relation (absent → []). It is
narrower than JSON Schema's required, which is context-free and answers "what is always
present when read?".
A nullable field therefore remains required: the caller may supply null, but may not
omit the key. Presence and nullability remain separate concepts.
The bundle — several entities, relations included
Bundle.fromSchemas produces a self-contained $defs document. Each entity uses its lowercased
name, matching the value carried by relation.to.
const bundle = Bundle.fromSchemas({ post: Post, author: Author });
const { post, author } = bundle.toSchemas();
A relation is a pointer rather than an inlined sub-schema: Post → Author → Post produces
two references instead of recursive nesting. Card.toSchema keeps the names;
Bundle.toSchemas resolves them to the target entities expected by adapters.
Serialization limits
A card can contain only serializable data:
- a relation becomes a name.
Bundle.toSchemascan resolve the() => Authorthunk within a bundle. - a custom generator or conversion is referenced by name, then resolved against the
consumer's registry. An unknown name produces a local
unknown generatorerror.
The axes therefore do not accept JavaScript closures, which cannot be serialized.
rpc.discover — a whole app's card
A host answers the reserved rpc.discover operation with what it hosts. Each frond
publishes what it stores, what you may call, and what leaves on its own.
{
"fronds": [{
"name": "blog",
"entities": [
{ "name": "post", "schema": { /* a card */ } }
],
"facades": [{
"name": "post",
"ops": [
{ "name": "list", "kind": "query", "output": { "title": "Post", /* … */ } },
{ "name": "findById", "kind": "query", "output": { "title": "Post", /* … */ } },
{ "name": "create", "kind": "command", "input": { /* a card */ },
"output": { "title": "Post", /* … */ } },
{ "name": "delete", "kind": "command" },
{ "name": "publish", "kind": "command", "description": "Make the post public." }
]
}],
"facts": [
{ "name": "postPublished", "schema": { /* a card */ } }
]
}]
}
An operation always says its name and its kind — query reads, command writes, which
is the same call REST turns into GET versus POST. The rest is present when there is
something to say: input and output when the contract names a view, description
when the method carries a doc sentence, errors for the codes it can refuse, cardinality
for how much it answers (one, maybe, many, page, none), and listens for the fact it is handed. delete names neither view above because a boolean
is not a shape.
An output that IS an entity is titled with the entity's name, which is how a reader ties an
operation to the row class it answers — whatever the address is called. A facade may store
nothing — a health check, a computation, a search across several shapes: its operations are
there and no entity is. A fact with no schema announces a type the host does not store.
entities lists what an operation takes or answers, and nothing else: an entity no served
operation reaches stays off the card, or it would publish the shape of tables nobody can
reach — the auth tables, typically.
Why facts are a separate list
A fact is the opposite case. It carries no operation either, but
someone wrote that it leaves (Emit<PostPublished>), so publishing its shape honours a statement instead of
leaking one. Without this list a fact stopped at the repository boundary: colocation gives
the contract, fronds: gives only the location, and across two repositories there is no
colocation. The subscriber had to hand-copy the emitter's declaration.
facts is the same on every surface — a facade has an audience, a
fact does not.
The remote router reads facades to know which address answers where, and entities for
schemaFor. A fact is not routable: nobody calls it, it arrives.
Writing a Frond elsewhere
None of this assumes TypeScript. A frond written in another language joins the topology if it honours three things:
- the wire —
POST /_fougere/call, JSON-RPC 2.0,method = "address.op"; see The gradient. - the card —
rpc.discoverreturns the envelope above,x-fougere-version: 1. - the error vocabulary — a whole
FougereErrorinerror.data, itscodea semantic code; the-32000integer stays the JSON-RPC spec's.
The consumer then rebuilds the schema and validates data locally with the same rules.
demos/rust-frond/ is one, in Rust — one entity, three operations, no entity({...})
class anywhere.
demos/rust-frond7 files[package]
name = "telemetry-frond"
version = "0.1.0"
edition = "2021"
publish = false
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
Next: Standard Schema — the live validator, handed to any library.