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 judge to
a library in the same process; the card is the serializable description of the same
declaration, for exchanges across processes or languages.
import { describe, reconstruct } from '@fougere/schema';
const card = describe(Post); // schema → JSON document
const Rebuilt = reconstruct(card); // document → living schema (validate + from)
describe produces the canonical document used by adapters. reconstruct 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 judge and the type of what it judges, exactly like class Post extends entity({…}) {}. This is what fougere sync
writes:
class Post extends reconstruct<{ id: string; title: string }>(card) {}
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" } }
}
},
"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.
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 | true | storage constraint — the DDL emits it |
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; describe 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
describeSet produces a self-contained $defs document. Each entity uses its lowercased
name, matching the value carried by relation.to.
const bundle = describeSet({ post: Post, author: Author });
const { post, author } = reconstructSet(bundle);
A relation is a pointer rather than an inlined sub-schema: Post → Author → Post produces
two references instead of recursive nesting. reconstruct keeps the names;
reconstructSet resolves them to the target entities expected by adapters.
Serialization limits
A card can contain only serializable data:
- a relation becomes a name.
reconstructSetcan 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 two lists, and they are duals: what you may call, and what leaves on its own.
{
"fronds": [{
"name": "blog",
"doors": [{
"name": "post",
"ops": [
{ "name": "list", "kind": "query", "output": { /* a card */ } },
{ "name": "findById", "kind": "query", "output": { /* a card */ } },
{ "name": "create", "kind": "command", "input": { /* a card */ },
"output": { /* a card */ } },
{ "name": "delete", "kind": "command" },
{ "name": "publish", "kind": "command", "description": "Make the post public." }
],
"schema": { /* a card */ }
}],
"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, and description
when the method carries a doc sentence. delete names neither view above because a boolean
is not a shape.
schema is optional on both lists, and its absence says something different on each. A
door with no schema stores nothing — a health check, a computation, a search across
several shapes. A fact with no schema announces a type the host does not store.
Why facts are a separate list
Hosting means answering: an entity with no façade is deliberately absent from doors, or
the card would publish the shape of tables nobody can reach — the auth tables, typically.
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, remotes: 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 door has an audience, a
fact does not.
The doublure router reads doors only, and uses them to associate entities with the
addresses declared in remotes. 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 = "entity.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.
Next: Handlers — operations, binding rules, output scoping.