Skip to content

Manifest Schema Reference

The ModuleManifest is the single contract every UI module (federation remote or iframe consumer) declares itself through. This document explains what each part of the shape is for and how the platform signals deprecation. For the mechanics of how a manifest travels from a repo to a user’s browser, see Manifest pipeline & deployment.

Artifact Role
whisker-platform/packages/whisker-module-kit/src/schema/index.ts Defines the shape. Every consumer (kit CLI, the Lambda, whisker-host) imports this exact Zod object — there’s no second copy to drift.
planning/moduleManifest.json A complete, schema-valid worked example.
This document Field-by-field purpose and the deprecation-signaling design.

If any of these disagree, the schema file wins.

ModuleManifest = {
manifestVersion: 1,
enabled: boolean,
identity: IdentityConfig,
integration: IntegrationConfig, // discriminated union: federation | iframe
access: AccessConfig,
deployment: DeploymentConfig,
ui: UiContributionsConfig,
capabilities: CapabilitiesConfig,
};

manifestVersion and enabled sit outside the six namespaces on purpose — they’re platform bookkeeping, not a team’s declared concern. manifestVersion is a discriminator reserved for the next breaking schema change. enabled is admin-owned runtime state: mergeManifestForUpsert preserves whatever’s already in DynamoDB on every re-ingest after the first, so a module’s own manifest can never silently flip itself back on in production.

{
name: string;
description: string;
teamName: string;
}

The canonical name (S3 key prefix, DynamoDB partition key, and the Lambda’s cross-check that an uploaded manifest matches its bucket path). Pure self-declaration — nothing here is admin-editable.

integration — how the host loads this module

Section titled “integration — how the host loads this module”
| { kind: 'federation'; remoteAssetUrls: {local,test,prod}; pages: PageComponent[] } // pages.min(1)
| { kind: 'iframe'; urls: {local,test,prod}; sandbox?: string }

A discriminated union, not two independently-optional fields — “both” or “neither” is a shape that cannot be constructed, not a validation error to catch. Federation modules must declare at least one page (enforced by the type, not a superRefine); iframe consumers have no pages field at all since the child owns its own routing. See Host ↔ module contract for how the host actually loads each kind.

{ allowedPersonas: AllowedPersona[] } // 'ALL' | 'ADMIN' | 'COACH' | 'DEVELOPER' | 'OPS' | 'SUPPORT'

Module-level gate, separate from each component’s own allowedPersonas. A user must clear both to see any individual widget/page/nav item. Admin-editable, same preservation rule as enabled.

deployment — where the code lives and how deploys get noticed

Section titled “deployment — where the code lives and how deploys get noticed”
{
branches: { test: string; prod: string };
incidentManagement: { assignmentGroup, defaultImpact, defaultUrgency, itemAffected, service, serviceOffering };
notifications: { notifyOn: {...}; slack: { channel } };
repository?: string;
}

notifications here means deploy/Slack alerts only — deliberately unrelated to the (planned) user-facing notification system in planning/open-proposals.md §1; nesting under deployment keeps the two concepts from colliding on the same root-level name. repository is optional — it’s read by the nightly E2E runner to clone the module for its own tests; modules that skip the nightly run don’t need it.

ui — what the module contributes to the host’s chrome

Section titled “ui — what the module contributes to the host’s chrome”
{
avatarMenu: AvatarMenuComponent[];
appHeader: AppHeaderComponent[];
navigation: NavigationComponent[];
widgets: WidgetComponent[];
}

pages is not here — it lives under integration.federation.pages because iframe consumers can never have one. All four buckets share BaseComponentSchema (name, enabled, componentPath, allowedPersonas, order) with bucket-specific extensions (widgets adds sizing constraints, avatarMenu adds section, appHeader adds icon). navigation has its own shape instead, since:

  • pageId links nav entries to integration.pages (single source of truth for federation routes)
  • child navigation entries intentionally have no icon field
  • componentPath is optional and only for custom nav icon components (default scaffolding omits it)
  • host nav ordering is deterministic: explicit order first, then alphabetical name, then id

Every bucket, plus integration.pages for federation modules, enforces name-uniqueness within the bucket via superRefine — matching the host’s de-dup key and the Lambda merge’s matching key.

capabilities — what the platform must discover ahead of time

Section titled “capabilities — what the platform must discover ahead of time”
{ taskSources: TaskSource[] } // defaults to []

Today holds exactly one thing: TaskSource — a pointer (sourceId, teamName, context, statusEndpoint, actionUrlTemplate) a team uses to register a category of task instances a future orchestrator should know about. It’s a pointer, not a data dump — actual task instances live in and are served by the owning team’s own backend. See planning/open-proposals.md §2 (schema capability shipped; consuming orchestrator not built).

The admission test for this namespace: does the host/orchestrator need to know this exists ahead of any specific event, so it can enumerate, render, or query it? widgets, pages, nav items, and TaskSource all pass. A notification trigger fails on purpose — it’s a one-off runtime event with no standing registration to discover, which is why capabilities.notifications doesn’t exist even though an earlier draft had it.

Namespaced by concern, not by feature-of-the-week. The root used to be flat, until two unrelated initiatives both reached for a root-level notifications name. Grouping by what kind of thing a field is about catches that class of collision before it reaches implementation. The alternative — a generic extensions: Record<string, unknown> escape hatch — was rejected because it moves validation out of Zod and defeats “the schema is the single source of truth.”

A discriminated union for integration, not two independent optional fields with a runtime check, makes an invalid combination a type that cannot be constructed rather than a validation error to write a test for. Two entirely separate root schemas were rejected too — that would have duplicated identity/access/deployment/ui for no benefit, since only integration actually varies by kind.

manifestVersion while it’s free costs nothing today (z.literal(1)) and gives the next breaking change an explicit field to switch on, rather than every consumer duck-typing the shape.

  1. Every capability is additive and optional with a safe default — never a new required field.
  2. One named, independently-testable sub-schema per capability, composed into the root — don’t grow any object inline.
  3. The manifest holds pointers and permissions, not business configuration.
  4. Deprecation is additive-first — add the new field alongside the old one and support both for a release window (same pattern as title aliasing name in navigation).
  5. One ownership line per capability — who reviews changes, what the Lambda validates for it.

Run any candidate for the capabilities namespace through the discoverability test above first — most new platform features are not manifest concerns at all.

Field/object lifecycle and deprecation signaling

Section titled “Field/object lifecycle and deprecation signaling”

Not yet implemented — this is a recommendation, not something already built.

The question: should manifest fields carry their own lifecycle status, baked into the schema? Recommendation: no — extend the contract (whisker-contract.json), not the schema. A lifecycle tag in Zod conflates “is this valid right now” with “is this going away,” and would require every module to bump a kit dependency just to see a notice — the exact problem the contract/update-check mechanism already exists to avoid. exposedSelectors in the contract already signals drift on one axis (the host’s API to modules); it says nothing about fields inside the manifest that modules author.

Proposed shape — a deprecations ledger on WhiskerContract, covering both axes with one mechanism:

interface DeprecationNotice {
area: 'manifest-field' | 'exposed-selector' | 'shared-package';
path: string; // e.g. "ui.navigation[].title" or "useIsNavOpen"
status: 'deprecated' | 'sunset';
since: string; // contract schemaVersion this first appeared in
removeBy?: string;
message: string;
remediation?: string;
}

Host-generated, regenerated on every host deploy, same as exposedSelectors today — not in the Zod schema or any npm-versioned package. This would let the host flag a deprecation without any module bumping a dependency, and reuses the findings/severity/remediation pipeline whisker update-check already has. Authorship would stay manual, same posture as exposedSelectors today. See Risks & improvements for where to track picking this up.