feat: make product features a declared selection with a runtime kill switch

Which features a build contains was not a decision anybody could express. The
reference feature was spread directly into the route, API, schema, codec and
adapter registries, so shipping without it meant editing five files by hand and
hoping nothing still referred to it — and there was no way at all to take it out
of service on a running deployment. The removability gate proved the editing
worked; nothing made it a choice.

There is now one manifest. `VITE_PRODUCT_FEATURES` narrows it at build time and
`FEATURE_OVERRIDES` in the runtime document takes an installed feature out of
service without a rebuild. Every registry composes from the manifest, and a test
fails if a new one forgets to.

Both inputs are subtractive, and the vocabulary is what enforces it rather than
a check somewhere downstream: the override enum has no `ENABLED`, and a
build-time selection naming something the source tree does not declare is
refused instead of ignored. A configuration document that could name a feature
into existence would be a configuration document choosing which code runs.

Disabling is not just hiding. Withdrawing a route from navigation would leave a
typed deep link that still mounts the feature, so the router refuses it too and
answers with a surface that says the deployment switched it off. The platform
overview now distinguishes the three states an operator actually needs: serving,
switched off, and not in this build.

What this is not: an env var does not shrink the bundle. A static import cannot
be undone by a value, and making the import graph itself depend on a
configuration string is the thing §3.5 exists to prevent — measured, `none`
changes the output by 58 bytes. Physical removal remains FE-GATE-020's job, and
the code comments say so rather than implying otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-15 20:45:19 +09:00
co-authored by Claude Opus 5
parent 0a97d235e4
commit 711d61e73f
36 changed files with 875 additions and 26 deletions
+2 -1
View File
@@ -5,7 +5,7 @@ import {
type ApplicationOutputPorts,
} from "../../src/application/create-application.ts";
import { createTestApplication } from "../helpers/create-test-application.ts";
import { createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts";
import { createProductFeaturesStub, createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts";
declare module "../../src/application/ports/in/application-api.ts" {
interface ApplicationFeatureInputs {
@@ -99,6 +99,7 @@ describe("application input/output boundary", () => {
}),
},
runtimeCapabilities: createRuntimeCapabilitiesStub(),
productFeatures: createProductFeaturesStub(),
navigation: { reload: () => {} },
} satisfies ApplicationOutputPorts;
const application = createApplication(ports);
+2 -1
View File
@@ -5,7 +5,7 @@ import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-
import type { StoragePort } from "../../src/application/ports/storage-port.ts";
import type { DiagnosticsPort } from "../../src/application/ports/diagnostics-port.ts";
import type { TelemetryPort } from "../../src/application/ports/telemetry-port.ts";
import { createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts";
import { createProductFeaturesStub, createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts";
type ReleaseFixture = {
buildId: string;
@@ -53,6 +53,7 @@ function applicationWith(options: {
diagnostics: options.diagnostics ?? { record: () => {} },
telemetry: options.telemetry ?? { emit: () => {} },
runtimeCapabilities: createRuntimeCapabilitiesStub(),
productFeatures: createProductFeaturesStub(),
releaseInfo: {
getCurrent: async () => current,
refresh:
+222
View File
@@ -0,0 +1,222 @@
import { describe, expect, it } from "vitest";
import {
activeProductFeatureIds,
resolveProductFeatures,
selectCompiledProductFeatures,
} from "../../src/contracts/product-features.ts";
import { runtimeConfigV2ArtifactSchema } from "../../src/contracts/release-artifacts.ts";
import {
COMPILED_PRODUCT_FEATURE_IDS,
INSTALLED_PRODUCT_FEATURE_IDS,
} from "../../src/features/installed-product-manifest.ts";
import {
ROUTE_FEATURE_OWNER,
ROUTE_REGISTRY,
} from "../../src/features/installed-feature-contracts.ts";
const COMPILED = Object.freeze([
Object.freeze({ featureId: "reference-feature" }),
Object.freeze({ featureId: "billing" }),
]);
describe("build-time product selection", () => {
it("keeps everything when nothing is declared", () => {
for (const declared of [undefined, "", " "]) {
expect(
selectCompiledProductFeatures(COMPILED, declared).map((f) => f.featureId),
String(declared),
).toEqual(["reference-feature", "billing"]);
}
});
it("narrows to the declared subset", () => {
expect(
selectCompiledProductFeatures(COMPILED, "billing").map((f) => f.featureId),
).toEqual(["billing"]);
expect(
selectCompiledProductFeatures(COMPILED, " billing , reference-feature ").map(
(f) => f.featureId,
),
).toEqual(["reference-feature", "billing"]);
});
it("selects nothing only when asked explicitly", () => {
// A blank value keeps everything on purpose: an unset CI variable expands
// to a blank string, and that must not be how a build ships no features.
expect(selectCompiledProductFeatures(COMPILED, "none")).toEqual([]);
expect(selectCompiledProductFeatures(COMPILED, " none ")).toEqual([]);
expect(selectCompiledProductFeatures(COMPILED, "").length).toBe(2);
// A value that parses to no names at all is a typo, not an instruction.
expect(() => selectCompiledProductFeatures(COMPILED, ",")).toThrow(
/names no feature/u,
);
});
it("refuses to name a feature this build does not contain", () => {
// The whole point of the direction rule: an environment value can subtract
// from the source tree and must never be able to add to it. Accepting an
// unknown id silently would let a deployment believe it had switched on
// something that is not in the bundle.
expect(() => selectCompiledProductFeatures(COMPILED, "analytics")).toThrow(
/does not contain: analytics/u,
);
expect(() =>
selectCompiledProductFeatures(COMPILED, "billing,analytics"),
).toThrow(/analytics/u);
});
it("refuses a duplicated feature id in the manifest", () => {
expect(() =>
selectCompiledProductFeatures(
[{ featureId: "a" }, { featureId: "a" }],
undefined,
),
).toThrow(/duplicate product feature id/u);
});
});
describe("runtime product feature resolution", () => {
it("reports active, disabled and not-installed distinctly", () => {
const statuses = resolveProductFeatures(
["reference-feature", "billing"],
["reference-feature"],
{ "reference-feature": "DISABLED" },
);
expect(statuses).toEqual([
{ featureId: "billing", state: "NOT_INSTALLED" },
{ featureId: "reference-feature", state: "DISABLED_BY_CONFIG" },
]);
expect(activeProductFeatureIds(statuses)).toEqual([]);
});
it("cannot switch on a feature the build left out", () => {
// `DEFAULT` on an uninstalled feature is not an instruction to install it.
const statuses = resolveProductFeatures(["billing"], [], {
billing: "DEFAULT",
});
expect(statuses).toEqual([{ featureId: "billing", state: "NOT_INSTALLED" }]);
expect(activeProductFeatureIds(statuses)).toEqual([]);
});
it("ignores an override naming a feature this build never declared", () => {
// A shared runtime document may cover several builds, so a stale key is
// inert rather than fatal.
const statuses = resolveProductFeatures(
["reference-feature"],
["reference-feature"],
{ analytics: "DISABLED" },
);
expect(activeProductFeatureIds(statuses)).toEqual(["reference-feature"]);
});
it("leaves an installed feature active without an override", () => {
const statuses = resolveProductFeatures(
COMPILED_PRODUCT_FEATURE_IDS,
INSTALLED_PRODUCT_FEATURE_IDS,
);
expect(activeProductFeatureIds(statuses)).toEqual([
...INSTALLED_PRODUCT_FEATURE_IDS,
]);
});
});
describe("runtime config carries the switch", () => {
const base = {
APP_ENV: "local" as const,
API_BASE_URL: "http://localhost:8080/",
TELEMETRY_ENABLED: false,
AUTH_MODE: "demo" as const,
CONFIG_SCHEMA_VERSION: "2.0" as const,
RELEASE_MANIFEST_URL: "/release-manifest.json",
};
it("defaults to disabling nothing", () => {
const parsed = runtimeConfigV2ArtifactSchema.parse(base);
expect(parsed.FEATURE_OVERRIDES).toEqual({});
});
it("accepts only DEFAULT or DISABLED", () => {
expect(
runtimeConfigV2ArtifactSchema.parse({
...base,
FEATURE_OVERRIDES: { "reference-feature": "DISABLED" },
}).FEATURE_OVERRIDES,
).toEqual({ "reference-feature": "DISABLED" });
// There is no "ENABLED": the vocabulary itself is what makes the rule
// unbreakable, not a check somewhere downstream.
expect(
runtimeConfigV2ArtifactSchema.safeParse({
...base,
FEATURE_OVERRIDES: { "reference-feature": "ENABLED" },
}).success,
).toBe(false);
});
it("refuses a malformed feature id", () => {
for (const featureId of ["Reference", "reference_feature", "", "-x"]) {
expect(
runtimeConfigV2ArtifactSchema.safeParse({
...base,
FEATURE_OVERRIDES: { [featureId]: "DISABLED" },
}).success,
featureId,
).toBe(false);
}
});
});
describe("every installed registry consults the manifest", () => {
/**
* The manifest only means something if each registry actually asks it. A new
* registry that spreads a feature in directly would reintroduce exactly the
* coupling this file exists to remove, and nothing else would notice.
*/
it("gates every feature contribution on the selection", async () => {
const { readdir, readFile } = await import("node:fs/promises");
const nodePath = (await import("node:path")).default;
const root = "src/features";
const registries = (await readdir(root)).filter((entry) =>
/^installed-.*\.tsx?$/u.test(entry),
);
expect(registries.length).toBeGreaterThan(3);
const exempt = new Set([
// The manifest is the selection.
"installed-product-manifest.ts",
// Capabilities have their own §3.5 selection file and override vocabulary.
"installed-runtime-capabilities.ts",
// Message keys stay total on purpose; see the file for why.
"installed-feature-messages.ts",
]);
for (const registry of registries) {
if (exempt.has(registry)) continue;
const source = await readFile(nodePath.join(root, registry), "utf8");
expect(
/INSTALLED_PRODUCT_FEATURE(S|_IDS)/u.test(source),
`${registry} must compose from the product manifest`,
).toBe(true);
}
});
});
describe("route ownership", () => {
it("attributes every feature route to its feature and no platform route", () => {
for (const featureId of INSTALLED_PRODUCT_FEATURE_IDS) {
expect(Object.values(ROUTE_FEATURE_OWNER)).toContain(featureId);
}
// Platform routes have no owner, so disabling a feature can never withdraw
// the shell's own navigation.
for (const routeId of ["APP_HOME", "NOT_FOUND", "EXAMPLES_PLATFORM"]) {
expect(ROUTE_FEATURE_OWNER[routeId], routeId).toBeUndefined();
expect(Object.keys(ROUTE_REGISTRY)).toContain(routeId);
}
});
it("owns exactly the routes the registry received from features", () => {
const owned = Object.keys(ROUTE_FEATURE_OWNER);
expect(owned.length).toBeGreaterThan(0);
for (const routeId of owned) {
expect(Object.keys(ROUTE_REGISTRY), routeId).toContain(routeId);
}
});
});
+1
View File
@@ -74,6 +74,7 @@ const runtimeV2 = {
SERVICE_WORKER: "DEFAULT",
OFFLINE_COMMANDS: "DEFAULT",
},
FEATURE_OVERRIDES: {},
} as const satisfies RuntimeConfigArtifact;
async function releaseV2With(
+2
View File
@@ -26,6 +26,7 @@ const runtime: Runtime = {
SERVICE_WORKER: "DEFAULT",
OFFLINE_COMMANDS: "DEFAULT",
},
FEATURE_OVERRIDES: {},
},
configSchema: "V2",
build: {
@@ -262,6 +263,7 @@ describe("runtime adapter composition", () => {
...runtime.config.CAPABILITY_OVERRIDES,
SERVICE_WORKER: "DISABLED",
},
FEATURE_OVERRIDES: {},
},
},
release,