The catalog snapshot
Version: 1
A catalog snapshot is a tenant's catalog as plain JSON: the adapters it can construct and their schemas, its credential names, its plugins, the models it can reach, and its saved movement files. The checker, the editor and both engines read it; nothing in it is a live object, and it never carries a credential's secret or id (those travel beside it, as a run's credentialIds).
- The type:
CatalogSnapshot(packages/movement-lang/service/snapshot.ts, re-exported byengine-contracts). - Read by:
fromCatalogSnapshot(the TypeScript checker, the editor, the TypeScript engine's check) andCatalogSnapshot::load(crates/movement-parser/src/catalog/, the Rust catalog the Rust engine and the browser parser load). The Rust reader refuses any member the TypeScript does not declare. - Built by: a host, per tenant and per run (lf-platform builds it per team in
apps/api/src/services/translation_graph/movement/catalog.ts;lfbuilds it from the project), andcatalogSnapshotOf(service/snapshot.ts) over any liveCatalog.
Versions #
- The version is an integer, stated at the top of this document and held by
CATALOG_SNAPSHOT_VERSION. This release reads version 1 only (SUPPORTED_CATALOG_SNAPSHOT_VERSIONS; the index lists each release). - On the wire: a snapshot carries
contractVersion. Both builders set it. It is optional on read: a snapshot without one (written before snapshots carried it, or a hand-written fixture) reads as version 1. - Refusal: a reader handed a version it does not read refuses it rather than reading it as the current one. The TypeScript reader throws, and the Rust reader answers an error, with the same message:
the catalog snapshot carries contract version 2, which this release does not read (supported: 1). - What bumps it: a change in meaning. A member removed or renamed, a member whose meaning changed, or a required member added. A new optional member keeps the version, but note the Rust reader's strictness: a new member must be declared in its types before a builder may write it, or every Rust read of a snapshot carrying it fails.
- How a break lands: the builder writes the new version; a reader that reads both branches on
contractVersionat the point of difference.
The shapes #
The shapes are TypeScript-ish: x?: T may be absent, and an absent member and one that is undefined are the same.
interface CatalogSnapshot {
contractVersion?: number; // absent: 1
adapters: Record<string, AdapterSnapshot>; // by adapter type
credentials: Record<string, { adapters: string[]; credential?: string } | { adapter: string }>; // by import name; `{ adapter }` is the older single-adapter form; `credential`: the module's declared credential it fills
plugins: Record<string, PluginSpec>; // by key: module and name (`@lf/web/render`) for a package's plugin, else the name
files?: Record<string, { source: string }>; // saved movement files, by import path
models?: string[]; // logical model names this deployment reaches; absent: models unchecked
}
interface AdapterSnapshot extends AdapterSpec {
schemas: Record<string, InstanceSchema>; // by instance key: the credential import name, plus `::<entry position>` when the construction pins one; '' is credential-free
schemaNotes?: Record<string, string[]>; // why a key's schema is missing, keyed like `schemas`
constructionArgOptions?: Record<string, Record<string, string[]>>; // entry-position arg values, by credential import name then arg name
}
interface AdapterSpec {
constructionArgs: ConstructionArg[];
declaredCredentials?: string[]; // a module's declared credentials, by name; a credential naming another (`credential`) is refused at check
canFire?: boolean; // absent on a known spec: it cannot fire anything
triggerConfig?: string[];
triggerConfigOptions?: Record<string, string[]>;
defaultEvents?: string[];
triggerConfigRequired?: string[];
triggerConfigRequiredWhy?: Record<string, string>;
triggerConfigFormats?: Record<string, 'cron' | 'timezone' | 'fields' | 'strings'>;
connectActions?: Array<{ kind: string; label: string }>;
connect?: 'oauth' | 'key-entry' | 'intrinsic' | 'handshake' | 'app-only';
triggerExpectation?: string;
schemaShape?: 'static' | 'introspected';
description?: string; // the adapter's own words for itself (its manifest's `description`); shown, never checked
}
interface ConstructionArg {
name: string;
kind: 'credential' | 'position';
required: boolean;
optionsFromType?: string;
label?: string;
}
interface PluginSpec {
args: string[];
priced: boolean;
argTypes?: Record<string, SchemaFieldType>; // each argument's declared input type, by name; absent for one nobody typed
requiredArgs?: string[];
effects?: { reads?: string[]; writes?: string[]; ai?: boolean; now?: boolean; suspend?: boolean };
fedByExtraction?: boolean;
fedArgs?: Array<{ name: string; required?: true }>;
output?: PluginOutput;
earlierOutputs?: Array<{ before: number; output: PluginOutput }>; // the output under language versions before `before`
package?: string; // the package tag it is a member of (`@lf/web`); absent when it is in none
name?: string; // the name a program spells it (`render`), when the key is not that; absent: the key
description?: string; // what it does, in its author's words (its signature's `description`); shown, never checked
}
A plugin's arguments by position (language version 5). A call passes the plugin's required inputs by position, then its other inputs in one settings record (fetchUrl(u), research(n, { context: s })). The positions are the spec's required fedArgs, then its requiredArgs in args order; a through [ … ] stage, which is never fed by its author, takes only the requiredArgs. The spec keeps a plugin's fed inputs apart from its author's, so it does not say how the two interleave in the manifest; every plugin declares its fed input first, so fed-then-author is the manifest's declared order. A settings key is an input's name exactly as args (or, on a plain call, fedArgs) spells it. The call is bound to the named config before the plugin is invoked, so the wire is unchanged: the plugin is handed { url: … } as under version 4. One binding does this in each language (Rust PluginSpec::bind_args, TypeScript bindPluginArgs); both checkers and both engines use it.
A plugin's output that is one file. A value output of type file is the file the host put at the answer's data.value, not the fetched text (a PDF: pdf.render(text)).
A plugin package (language version 5). A spec's package is a tag (@lf/web), compared exactly and never parsed. An import from plugins whose name equals some spec's package imports that package: import { `@lf/web` as web } from plugins. Its members are the specs that declare that tag, each called by its key in camelCase after the package's local name (fetch_url → web.fetchUrl(u), through [web.linkedinEnrichment]). A member call is an ordinary plugin call: it binds by position as above, and the plugin is invoked by its catalog key with the named config. The platform's catalog lists two: @lf/web and @lf/pdf (pdf.render(text)). A package's plugin is keyed by module and name (@lf/pdf/render, @lf/web/render), so two modules can each serve a render; the spec's name is what a program writes. A bare import (import { fetch_url } from plugins, as versions 1 to 4 write it) finds the one plugin of that name, and is refused, naming the packages, when several have it. The key is compared, never parsed. name is optional and additive, so the version stays 1. The member is optional and additive, both readers carry it, so the version stays 1.
type PluginOutput =
| { kind: 'value'; type: SchemaFieldType }
| { kind: 'record'; fields: Record<string, SchemaFieldType> }
| { kind: 'records'; fields: Record<string, SchemaFieldType> };
type SchemaFieldType =
| 'text' | 'number' | 'boolean' | 'date' | 'datetime' | 'file' | 'json' | 'absent'
| { kind: 'list'; of: SchemaFieldType; unordered?: true }
| { kind: 'tuple'; of: Array<SchemaFieldType | null> }
| { kind: 'dict'; of: SchemaFieldType }
| { kind: 'enum'; options: string[]; open?: { allowPattern?: string } }
| { kind: 'maybeAbsent'; of: SchemaFieldType };
interface InstanceSchema {
positions: Record<string, PositionSchema>;
collections: Record<string, { target: string; capability?: EdgeCapability }>;
unions?: Record<string, string[]>;
unionDisplayNames?: Record<string, string>;
writableRoots: Record<string, WritableRootSchema>;
createShapes?: Record<string, WritableRootSchema>;
supportsInPlaceUpdate?: boolean;
edgesCarryProperties?: boolean;
refinements?: Record<string, string>;
selectedMembers?: Record<string, string>;
narrowBy?: Record<string, string[]>;
genericLandings?: Record<string, string>;
eventPosition?: string;
eventPositions?: Array<{ position: string; on?: string[] }>;
eventNarrowingKeys?: string[];
eventNarrowingValues?: Record<string, Record<string, string[]>>;
}
interface PositionSchema {
properties: Record<string, SchemaFieldType>;
edges: Record<string, EdgeSchema>;
displayName?: string;
propertyCapabilities?: Record<string, FieldCapability>;
openProperties?: boolean;
undescribed?: boolean;
pending?: boolean;
writeOnlyProperties?: string[];
ambiguousProperties?: string[];
deletable?: boolean; // the system removes a record of this type; under language version 5 a `delete` of a type without it is refused
description?: string; // the adapter's words for the type (its describe's, else its entry point's, `description`); shown, never checked
fieldDocs?: Record<string, string>; // the adapter's words for each field it described, readable or write-only, by name
}
interface WritableRootSchema {
fields: Record<string, SchemaFieldType>;
resultShape: Record<string, SchemaFieldType>;
discriminated?: { discriminant: string; variants: Record<string, WritableRootSchema> };
writeUnion?: { variants: Array<{ name: string; fields: string[] }> };
position?: string;
nativeUniqueness?: string[][];
requiredEdges?: Array<{ edge: string; from: string }>;
edges?: Record<string, EdgeSchema>;
requiredFields?: string[];
fieldDocs?: Record<string, string>; // the writable fields' words only; the position's `fieldDocs` holds every field's
fuzzyResolution?: true | string[];
uniquenessAuthorable?: boolean;
}
interface EdgeSchema {
target: string;
polymorphic?: boolean;
required?: boolean;
capability?: EdgeCapability;
sequenced?: 'chronological' | 'document' | 'arrival';
ephemeral?: boolean;
readable?: boolean;
writable?: boolean;
linkable?: boolean;
requiresLiveRecord?: boolean;
subject?: boolean;
awaitable?: boolean;
resolvesEmpty?: boolean;
watchable?: boolean;
genericOver?: { field: string; onNonLiteral?: 'error' | 'warn' };
resources?: true; // the system's resources edge (the describe's reserved `_resources` reference): walked by its name, sent to the adapter by the reserved field id
description?: string; // the adapter's words for the edge (its reference's `description`); shown, never checked
}
interface EdgeCapability {
filter?: 'bounded' | 'native';
order?: 'bounded' | 'native';
supportsLimit: boolean;
naturalOrder?: NaturalOrder;
}
interface NaturalOrder {
field: string;
direction: 'asc' | 'desc';
}
interface FieldCapability {
filterOperators?: Array<'eq' | 'neq' | 'contains' | 'gt' | 'gte' | 'lt' | 'lte' | 'exists' | 'in' | 'within'>;
orderable?: boolean;
}
EdgeSchema is the catalog's (packages/movement-lang/catalog/types.ts); EdgeCapability, NaturalOrder and FieldCapability are shared with the expression layer (packages/movement-lang/expression/types.ts). A test holds the member names and optional marks of every interface on this page to those sources. A schema's field types are SchemaFieldType only: the Rust reader refuses a field typed with what only the checker's typing builds (a record, a union).
The file type is the envelope that makes a value a file. Both engines read a position's property types (PositionSchema.properties, the read wrapper's natural type) and a plugin output's field types (PluginOutput), and make a file of each value at a file position — file itself, a list's or a dict's members of that type, a tuple's slots, a maybe-absent one — where the value spells a file's descriptor (engine-contracts fileDescriptorOf). A value at any other type is data, whatever keys it carries; a value at a file type that spells no descriptor stays data too. No reader classifies a value by its keys.
Changes #
- Version 1 (the first version on the wire). (This page first omitted five optional
InstanceSchemamembers,genericLandingsand the fourevent…members, and inlined no capability types; they were on the wire all along and are documented now. No version change: the shapes did not change.) The snapshot gainscontractVersion, which both builders set and both readers check. Everything else is the snapshot as it was before it was versioned, when compatibility rested on optional members alone (files,models,schemaNotes,constructionArgOptionsand the positioned instance keys were each added that way). A plugin'sargTypes(each argument's declared input type) was added the same way after versioning, so the version stays 1. Since language version 4 (main's bcd8f67407) a write handle'sresultShapeno longer listscreatedandcommitted: the host leaves them out, and the checker adds them back for a program pinned before version 4. A reader that took them from the snapshot reads them from the checker instead; the version stays 1. Since PLAN-07A Task 4d thefilefield type is what makes a field's value a file (above): a descriptor at an untyped orjsonfield, read as a file before, is data. No member changed, so the version stays 1. With language version 5 (Dancing Wren) aPositionSchemagainsdeletable?(the describe's per-type promise thatdeleteRecordremoves one) and anEdgeSchemagainsresources?(the system's resources edge, sent to the adapter by its reserved field id). Both are optional and additive, both readers carry them (the Rust reader declares both, so a snapshot carrying them still loads there), and nothing strips them, so the version stays 1. The adapter's own words then ride the snapshot for an editor to show:description?on anAdapterSpec(the manifest's), aPositionSchema(the type's), anEdgeSchema(the reference's) and aPluginSpec(the signature's), and aPositionSchema'sfieldDocs?(every described field's, where aWritableRootSchema's holds only the writable ones). No checker reads them except for the words a write-only field's diagnostic quotes; all are optional and additive and both readers declare them, so the version stays 1. A Rust reader built before them refuses a snapshot that carries them.