The Manifest IR
How the decorated @Product class compiles to deterministic, validated intermediate representation.
How the decorated @Product class compiles to deterministic, validated intermediate representation.
The Manifest IR is what your @Product class becomes. The SDK runs
the decorators, assembles a single JSON envelope, validates it against the platform schema, and
hashes it. Core accepts the IR — not your TypeScript — so the IR is the contract boundary. It is
deterministic by construction: the same product always produces byte-identical bytes and the same
hash.
compileProductToManifest(product) returns { ir, irHash }. The ir is a ManifestIrDocument:
type ManifestIrDocument = {
irVersion: 1;
sdkVersion: string;
product: ProductSpecJson; // product block, metering, plans, surfaces, frontend, resources, …
routes: RouteLayerJson[]; // one layer per @Feature, routes in declaration order
policies: PolicyLayerJson[];
capabilities: CapabilityLayerJson[];
};
product.product holds the product block (name, baseUrl = your origin, displayName);
product.metering.meters holds the compiled meters; product.plans holds the
sorted plans; routes holds one layer per feature. Runtime
state (rollout, flags, migrations) is not part of the IR — Manifest IR v1 is a pure snapshot,
and a top-level runtime key is rejected by validation.
product/product.config.ts and everything it imports must be deterministic: no Date.now(), no
randomness, no network, no process state. Collections that don't carry semantic order (plans,
meters, capabilities) are sorted by key, so declaration order doesn't change the bytes; routes keep
declaration order because the gateway matcher is first-match-wins.
The GitHub bot builds the IR twice and rejects the push if the two hashes differ. So a reordered-but-equivalent class hashes identically:
// Declaring @Plan("pro") before @Plan("starter"), or reversing meters, yields the SAME irHash —
// sorted collections are order-insensitive; only route order is preserved.
expect(compile(cronCloud()).irHash).toBe(compile(reorderedCronCloud()).irHash);
The IR hash is a 64-hex SHA-256 over the canonical JSON:
import { hashIr, canonicalIrJson } from "@farthershore/product";
const json = canonicalIrJson(ir); // stable, sorted, whitespace-normalized JSON string
const hash = hashIr(ir); // matches /^[0-9a-f]{64}$/
canonicalIrJson is the exact serialization the hash is taken over — useful for diffing two builds
or proving determinism in CI.
validateManifestIr(candidate) checks a candidate envelope against the bundled platform schema and
returns a discriminated result:
import { validateManifestIr } from "@farthershore/product";
const result = validateManifestIr(candidate);
if (result.ok) {
result.ir; // the JSON-normalized document (author's bytes, undefined keys dropped)
result.irHash; // its hash
} else {
result.issues; // [{ code, path, message }, …] — structured, path-addressed
}
Schema violations come back as structured issues with a dotted path, so you can pinpoint the
offending field:
@Product({ name: "bad", origin: "not-a-url", primaryColor: "red" })
// issues include paths: "product.product.baseUrl", "product.product.primaryColor"
On success, result.ir is the JSON-normalized original candidate, not a schema-transformed
value: validation proves the document is acceptable, but the bytes stay yours.
compileProductToManifest runs the structural schema validation (throwing a
ManifestValidationError with .issues on failure) and graph checks (missing references,
duplicate keys, route-metering conflicts throw ManifestBuilderError). A second, completeness
pass runs at build time:
import { validatePlanRateLimitCompleteness } from "@farthershore/product";
const issues = validatePlanRateLimitCompleteness(ir);
// → [{ code: "PLAN_RATE_LIMIT_REQUIRED", path: "product.plans.0.limits", message: … }]
// for every plan missing a rate-limit rule. Empty array = complete.
This is run by farthershore-manifest-build after a clean compile, and uses the same predicate as
the publish gate — so a plan that passes locally passes at publish. It is deliberately kept out of
compileProductToManifest so the byte-identity fixtures can compile intentionally-limitless products.
| Error | Thrown when |
|---|---|
ManifestBuilderError | Decoration/graph problems: missing origin, bad route key, duplicate plan/action id, undeclared meter reference, both-cost-and-report conflict, integer-like dimension key. |
ManifestValidationError | The assembled IR violates the platform schema (.issues carries the dotted paths). |
Both are exported from @farthershore/product. Builder errors fire as early as possible — most at
decoration time, the moment the class is defined — so a malformed product fails before it ever
reaches the platform compiler.
# Compile product/product.config.ts → validated, hashed Manifest IR
farthershore-manifest-build --entry product/product.config.ts --out manifest-ir.json
This is the same binary the GitHub bot, build-runner, and CLI share. A clean local build is the
same IR the platform will accept. To apply: commit product/** and push — the bot validates, builds
twice for determinism, and publishes the accepted release through Core so edge artifacts propagate.
The IR carries sdkVersion and irVersion: 1. Pin @farthershore/product together with
@farthershore/backend and @farthershore/farthershore-js at the same version — they release in
lockstep, and the SDK projects the platform contract at build time.
Start from the @Product class and work outward through meters, features, capabilities, and plans — every one of them is just more bytes in this envelope.