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.
Source of truth chain
Section titled “Source of truth chain”| 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.
The root shape at a glance
Section titled “The root shape at a glance”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.
Namespace-by-namespace
Section titled “Namespace-by-namespace”identity — who is this
Section titled “identity — who is this”{ 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.
access — who can see the module at all
Section titled “access — who can see the module at all”{ 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:
pageIdlinks nav entries tointegration.pages(single source of truth for federation routes)- child navigation entries intentionally have no icon field
componentPathis optional and only for custom nav icon components (default scaffolding omits it)- host nav ordering is deterministic: explicit
orderfirst, then alphabeticalname, thenid
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.
Why this shape
Section titled “Why this shape”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.
Guardrails for future schema changes
Section titled “Guardrails for future schema changes”- Every capability is additive and optional with a safe default — never a new required field.
- One named, independently-testable sub-schema per capability, composed into the root — don’t grow any object inline.
- The manifest holds pointers and permissions, not business configuration.
- Deprecation is additive-first — add the new field alongside the old one
and support both for a release window (same pattern as
titlealiasingnamein navigation). - 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.
Where to look next
Section titled “Where to look next”planning/moduleManifest.json— a complete worked example.- Host ↔ module contract — the
whisker-contract.jsonshape and how drift is currently surfaced. - Manifest pipeline & deployment — how a manifest gets from a repo into DynamoDB and onto a user’s screen.
whisker-platform/packages/whisker-module-kit/src/merge/index.ts— the admin-aware merge semantics referenced above.
