Module manifest reference

Operator·6 minutes to read

Every field of ModuleManifest and DataFlow, quoted directly from packages/module/src/manifest.ts — this page does not retype the interface, it quotes it, and scripts/docs-check.mjs fails the gate the moment a quote here stops matching the source. defineModule and validateModule, the package's two entry points, are quoted at the bottom.

The stability policy

The module API breaks only on a major. A manifest declares the apiVersion it was written against; a deployment refuses one written against a version it does not have. Additive changes — a new optional field, a new permission tag — keep the number; anything that could make an existing manifest invalid raises it, and raising it is a major release of @botseon/module under Changesets.

/**
 * F-DEV-2's module-API stability policy: **the module API breaks only on a major.** A manifest
 * declares the `apiVersion` it was written against, and a deployment refuses one written against
 * a version it does not have. Additive changes — a new optional field, a new permission tag —
 * keep the number; anything that could make an existing manifest invalid raises it, and raising
 * it is a major release of `@botseon/module` under Changesets (Task 9).
 */
export const MODULE_API_VERSION = 1;

/**
 * Whether this deployment can load a manifest declaring `manifestApiVersion`.
 *
 * Area review A13: the lower bound is not decoration. Without `>= FIRST_MODULE_API_VERSION` a
 * manifest declaring `apiVersion: 0` or `-3` — a typo, or a manifest generated before the field
 * meant anything — loaded as if it had been written against a version this deployment has, which
 * is the one thing this function exists to refuse.
 */
export const FIRST_MODULE_API_VERSION = 1;

export function isCompatible(manifestApiVersion: number): boolean {
  return (
    Number.isInteger(manifestApiVersion) &&
    manifestApiVersion >= FIRST_MODULE_API_VERSION &&
    manifestApiVersion <= MODULE_API_VERSION
  );
}

A module writes its own apiVersion field as MODULE_API_VERSION — the constant above, at the version of @botseon/module it was built against — not a hand-typed number. isCompatible is what a future loader (M4) calls to decide whether to load a manifest at all; a manifest whose apiVersion is higher than the loader's own MODULE_API_VERSION was written for a module API the deployment does not have yet, and gets refused rather than half-loaded. A manifest below FIRST_MODULE_API_VERSION is refused for the mirror-image reason: there has never been an API version below 1, so a 0 is a typo or a field that meant nothing when it was written.

ModuleManifest

export interface ModuleManifest {
  id: string; // reverse-dns, e.g. 'com.example.uptime'
  name: string;
  version: string; // semver
  apiVersion: number; // MODULE_API_VERSION at the time it was written
  description: string;
  tools: ModuleTool[];
  dataFlow: DataFlow;
  /** OAuth app the operator brings themselves (F-HOME-2). `null` for a module that needs none. */
  oauth: { provider: string; scopes: string[] } | null;
}
  • id — a reverse-DNS identifier (com.example.uptime; at least two lowercase, dot-separated labels, validated against the same grammar botseon module new uses to name the directory it scaffolds). Get this wrong and a loader that keys anything on it — a stored grant, a logged capability — cannot tell two versions of your module apart from a module somebody else wrote with a colliding short name.
  • name — the human-readable name a member sees. Cosmetic, but not free: it is what someone approves an action for, so a vague name ("Tool") makes every approval card it appears on worse at the one job an approval card has.
  • version — semver (0.1.0). Nothing in this package enforces monotonicity across publishes; a loader is what would compare two versions of the same id, and a version that goes backwards or never changes is how a fixed bug quietly stays shipped.
  • apiVersion — see the stability policy above. Hand-typing a number instead of writing MODULE_API_VERSION is the mistake this field exists to prevent; do not.
  • description — free text, shown wherever the manifest is. botseon module new writes a placeholder ('Replace this description.') precisely so a module that ships without replacing it is visibly unfinished rather than silently wrong.
  • tools — the array of ModuleTool (below). An empty array is a valid manifest that does nothing; there is no minimum.
  • dataFlow — required, no default (below). A manifest with no dataFlow does not validate at all: validateModule refuses it, because F-DP-4's transfer register and the Art. 30(2) record are generated from these declarations, and a module that skipped the field would be invisible to both.
  • oauth{ provider, scopes } for a module whose tool needs a third-party account, null for one that does not. Getting this wrong in either direction costs an operator real time: declaring it when nothing is needed sends someone to docs/connectors/ for a setup that does not exist; leaving it null for a tool that actually needs a token means the tool fails at the moment it is first called, with no earlier signal that anything needed configuring.

ModuleTool

export interface ModuleTool {
  name: string;
  description: string;
  /** The permission tags the policy engine reads for this tool (§2.6). Declared, not inferred. */
  tags: PermissionTag[];
  /** A zod schema, passed through unexamined by this package — validating a third party's schema
   *  is the loader's job at M4. */
  inputSchema: unknown;
}
  • name, description — what the tool is called and what it does, both shown to whatever calls it (a model deciding whether to use it, a member reading an approval card).
  • tags — the permission tags the policy engine acts on for this specific tool; see docs/reference/permission-tags.md for all ten and what choosing one wrongly costs. Declared, not inferred: this package does not look at what a tool's implementation actually does, so a tag list that does not match reality is a defect in the module, not something any validator here catches.
  • inputSchema — a zod schema, typed unknown here and passed through unexamined. Validating a third party's schema (as opposed to merely accepting that one was supplied) is the loader's job at M4, not this package's.

DataFlow

export interface DataFlow {
  /** What the module reads or writes, in the data-class vocabulary of `data-classes.json`. */
  dataClasses: string[];
  /** Every destination outside this deployment, by hostname. `[]` means the module makes no
   *  outbound call at all, and that is a claim the conformance suite (M4) checks. */
  destinations: Array<{ host: string; purpose: string; region: string | null }>;
  /** Whether any declared destination processes personal data (Art. 28). */
  processesPersonalData: boolean;
  /** Free text naming what is retained and for how long, or `null` for "retains nothing". */
  retention: string | null;
}
  • dataClasses — which data classes (data-classes.json's vocabulary) the module reads or writes. Omitting a class the module actually touches is how a data map ends up understating what a deployment processes — the exact failure a generated data map exists to prevent.
  • destinations — every outside host the module calls, with a purpose and a region, or [] for none. [] is a claim, not an absence of information: a future conformance suite (M4) checks a module against its own declared destinations, so a module that calls out while declaring none is a module lying about what F-DEV-3's README sentence already warns an operator to check for themselves.
  • processesPersonalData — whether any declared destination processes personal data (Art. 28). Wrong in the "no" direction means a real sub-processor relationship goes unrecorded in the generated Art. 30(2) record.
  • retention — free text naming what is kept and for how long, or null for "retains nothing". null on a module that actually retains something is the one value here with no technical check behind it at all — it is a claim the module author makes and nothing currently verifies, which is exactly why getting it right matters more than the fields a schema can catch.

The two entry points

export function defineModule(input: ModuleManifest): ModuleManifest {
  return input;
}

The identity function a module's own src/index.ts calls. It stamps nothing and mutates nothing — its only job is to give that call site an argument type checked against ModuleManifest, so a malformed manifest is a compile error in the module rather than a runtime surprise wherever it is later loaded.

export function validateModule(
  input: unknown,
): { ok: true; module: ModuleManifest } | { ok: false; errors: string[] } {

Structural validation for a manifest of unknown provenance — a third-party module a loader has not seen before. Every problem is collected and returned at once (error.issues, one string each) rather than stopping at the first, because fixing a manifest one error at a time is a bad first hour with the framework.

Last verified against build c0f77aa.