Merge branch 'main' into feature/techlog-ui-migration
Integrates the frontend template sync (a0fbafb → 5434760) into the TechLog UI migration. Merged in this direction so every conflict is resolved and proved in the worktree; main is only fast-forwarded afterwards and never holds a state that was not verified here. 15 conflicts. The rule throughout: keep the template's mechanism, keep the product's content, and never invent a third state neither branch would accept. The template's product manifest and its runtime feature kill switch are adopted. The route registry is deliberately not composed from contract.routes: the reference feature still declares screens this product deleted, and reducing over them would register paths with no component behind them. ROUTE_FEATURE_OWNER is narrowed to registered routes for the same reason. The first resolution did compose from contract.routes and was rejected by product-features.test.ts. Three files pinned counts and a digest describing the gate contract. Neither side's numbers describe the merged config/ci/gates.json, so they were recomputed from it rather than chosen: 27 gates, 82 commands, 94 command references, 107 evidence references, 128 artifacts, shape sha256 5063586d. README.md and docs/accessibility/manual-checklist.md now enumerate this product's 27 routes, which the template's own verify:documentation requires. product-feature-switch.test.tsx was rewritten around the invariant that still applies here — no registered route without a component — rather than deleted with the screens it used to exercise. docs/operations/template-merge-2026-08-17.md records every decision, the gate results, and the three follow-ups this merge deliberately did not decide. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
export const COMPATIBILITY_TUPLE_FIELDS = Object.freeze([
|
||||
"buildId",
|
||||
"configSchemaVersion",
|
||||
"apiContractVersion",
|
||||
"assetManifestHash",
|
||||
"releaseId",
|
||||
] as const);
|
||||
|
||||
export type CompatibilityTupleField =
|
||||
(typeof COMPATIBILITY_TUPLE_FIELDS)[number];
|
||||
|
||||
export type CompatibilityTuple = Readonly<
|
||||
Record<CompatibilityTupleField, string>
|
||||
>;
|
||||
|
||||
export type NumericVersion = Readonly<{
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
}>;
|
||||
|
||||
export function parseNumericVersion(version: string): NumericVersion | null {
|
||||
const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(version);
|
||||
if (!match) return null;
|
||||
return {
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2] ?? 0),
|
||||
patch: Number(match[3] ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function isVersionCompatible(
|
||||
supported: string,
|
||||
actual: string,
|
||||
): boolean {
|
||||
const expected = parseNumericVersion(supported);
|
||||
const candidate = parseNumericVersion(actual);
|
||||
if (!expected || !candidate) return false;
|
||||
return (
|
||||
expected.major === candidate.major &&
|
||||
candidate.minor >= expected.minor
|
||||
);
|
||||
}
|
||||
|
||||
export function verifyCompatibilityTuple(input: Readonly<{
|
||||
frontend: CompatibilityTuple;
|
||||
runtime: CompatibilityTuple;
|
||||
}>) {
|
||||
const mismatches: CompatibilityTupleField[] = [];
|
||||
if (input.frontend.buildId !== input.runtime.buildId) mismatches.push("buildId");
|
||||
if (
|
||||
!isVersionCompatible(
|
||||
input.frontend.configSchemaVersion,
|
||||
input.runtime.configSchemaVersion,
|
||||
)
|
||||
) {
|
||||
mismatches.push("configSchemaVersion");
|
||||
}
|
||||
if (
|
||||
!isVersionCompatible(
|
||||
input.frontend.apiContractVersion,
|
||||
input.runtime.apiContractVersion,
|
||||
)
|
||||
) {
|
||||
mismatches.push("apiContractVersion");
|
||||
}
|
||||
if (input.frontend.assetManifestHash !== input.runtime.assetManifestHash) {
|
||||
mismatches.push("assetManifestHash");
|
||||
}
|
||||
|
||||
const releaseWarning: "releaseId" | null =
|
||||
input.frontend.releaseId === input.runtime.releaseId
|
||||
? null
|
||||
: "releaseId";
|
||||
return Object.freeze({
|
||||
compatible: mismatches.length === 0,
|
||||
mismatches: Object.freeze(mismatches),
|
||||
warnings: Object.freeze(releaseWarning ? [releaseWarning] : []),
|
||||
});
|
||||
}
|
||||
|
||||
export type ObjectSchemaShape = Readonly<{
|
||||
required?: readonly string[];
|
||||
properties?: Readonly<Record<string, unknown>>;
|
||||
}>;
|
||||
|
||||
export type SchemaChangeClassification = "breaking" | "additive" | "none";
|
||||
|
||||
export function classifyObjectSchemaChange(
|
||||
before: ObjectSchemaShape,
|
||||
after: ObjectSchemaShape,
|
||||
): SchemaChangeClassification {
|
||||
const beforeRequired = new Set(before.required ?? []);
|
||||
const afterRequired = new Set(after.required ?? []);
|
||||
const removedProperties = Object.keys(before.properties ?? {}).filter(
|
||||
(key) => !(key in (after.properties ?? {})),
|
||||
);
|
||||
const addedRequired = [...afterRequired].filter(
|
||||
(key) => !beforeRequired.has(key),
|
||||
);
|
||||
if (removedProperties.length > 0 || addedRequired.length > 0) return "breaking";
|
||||
|
||||
const addedProperties = Object.keys(after.properties ?? {}).filter(
|
||||
(key) => !(key in (before.properties ?? {})),
|
||||
);
|
||||
return addedProperties.length > 0 ? "additive" : "none";
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Result } from "../application/result.ts";
|
||||
import type { Result } from "./result.ts";
|
||||
|
||||
export type CursorPage<Value> = Readonly<{
|
||||
items: readonly Value[];
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { RuntimeConfigArtifact } from "./release-artifacts.ts";
|
||||
|
||||
/**
|
||||
* §6.4. Which environment an artifact is allowed to be deployed to.
|
||||
*
|
||||
* Release coherence answers "do these artifacts describe each other?". It does
|
||||
* not answer "is this the artifact production should receive?", and the two are
|
||||
* not the same question: a build whose runtime document says `APP_ENV: local`,
|
||||
* `AUTH_MODE: demo` and `API_BASE_URL: http://localhost:8080/` is perfectly
|
||||
* coherent with itself. Without an admission step such a build is a valid
|
||||
* release candidate, and the only thing standing between it and production is
|
||||
* that nobody happened to promote it.
|
||||
*
|
||||
* Admission is therefore a separate, declared decision: a caller states the
|
||||
* target it intends, and this module says whether the artifact may go there.
|
||||
* Every rule below is a refusal, so an unrecognised target or an unreadable
|
||||
* field fails closed rather than passing by omission.
|
||||
*/
|
||||
|
||||
export const DEPLOYMENT_TARGETS = Object.freeze([
|
||||
"local",
|
||||
"development",
|
||||
"staging",
|
||||
"production",
|
||||
] as const);
|
||||
|
||||
export type DeploymentTarget = (typeof DEPLOYMENT_TARGETS)[number];
|
||||
|
||||
/**
|
||||
* Targets that serve real users over the public internet. They carry the full
|
||||
* rule set; `local` and `development` only have to be honest about what they
|
||||
* are.
|
||||
*/
|
||||
const PUBLIC_TARGETS: ReadonlySet<DeploymentTarget> = new Set([
|
||||
"staging",
|
||||
"production",
|
||||
]);
|
||||
|
||||
/** Placeholder identifiers a developer build emits when nothing supplied one. */
|
||||
const PLACEHOLDER_IDENTIFIERS: ReadonlySet<string> = new Set([
|
||||
"local-build",
|
||||
"local-release",
|
||||
"local",
|
||||
"dev",
|
||||
"unknown",
|
||||
]);
|
||||
|
||||
export type AdmissionViolation = Readonly<{ field: string; reason: string }>;
|
||||
|
||||
export type AdmissionInput = RuntimeConfigArtifact &
|
||||
Readonly<{ BUILD_ID?: string; RELEASE_ID?: string }>;
|
||||
|
||||
export function isDeploymentTarget(value: unknown): value is DeploymentTarget {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(DEPLOYMENT_TARGETS as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every reason this artifact may not be deployed to `target`. An empty list is
|
||||
* the only admission.
|
||||
*/
|
||||
export function findAdmissionViolations(
|
||||
target: DeploymentTarget,
|
||||
config: AdmissionInput,
|
||||
): readonly AdmissionViolation[] {
|
||||
const violations: AdmissionViolation[] = [];
|
||||
if (config.APP_ENV !== target) {
|
||||
violations.push({
|
||||
field: "APP_ENV",
|
||||
reason: `artifact declares ${config.APP_ENV} but is being admitted to ${target}`,
|
||||
});
|
||||
}
|
||||
if (!PUBLIC_TARGETS.has(target)) return Object.freeze(violations);
|
||||
|
||||
if (config.AUTH_MODE !== "external") {
|
||||
violations.push({
|
||||
field: "AUTH_MODE",
|
||||
reason: `${target} requires an external identity provider, not ${config.AUTH_MODE}`,
|
||||
});
|
||||
}
|
||||
violations.push(...publicEndpointViolations("API_BASE_URL", config.API_BASE_URL));
|
||||
if (config.TELEMETRY_ENABLED && !config.TELEMETRY_ENDPOINT) {
|
||||
violations.push({
|
||||
field: "TELEMETRY_ENDPOINT",
|
||||
reason: "telemetry is enabled without an endpoint",
|
||||
});
|
||||
}
|
||||
if (config.TELEMETRY_ENDPOINT) {
|
||||
violations.push(
|
||||
...publicEndpointViolations("TELEMETRY_ENDPOINT", config.TELEMETRY_ENDPOINT),
|
||||
);
|
||||
}
|
||||
for (const field of ["BUILD_ID", "RELEASE_ID"] as const) {
|
||||
const value = config[field];
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
violations.push({ field, reason: `${target} requires a build identity` });
|
||||
continue;
|
||||
}
|
||||
if (PLACEHOLDER_IDENTIFIERS.has(value.toLowerCase())) {
|
||||
violations.push({
|
||||
field,
|
||||
reason: `${value} is a developer placeholder, not a released identity`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Object.freeze(violations);
|
||||
}
|
||||
|
||||
function publicEndpointViolations(
|
||||
field: string,
|
||||
value: string,
|
||||
): readonly AdmissionViolation[] {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
return [{ field, reason: "is not an absolute URL" }];
|
||||
}
|
||||
const violations: AdmissionViolation[] = [];
|
||||
if (url.protocol !== "https:") {
|
||||
violations.push({ field, reason: `${url.protocol} is not permitted; use https` });
|
||||
}
|
||||
if (isNonPublicHost(url.hostname)) {
|
||||
violations.push({
|
||||
field,
|
||||
reason: `${url.hostname} is not reachable from a user's browser`,
|
||||
});
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts that only resolve inside the machine or network that built the
|
||||
* artifact. A deployment pointing at one of these is a developer configuration
|
||||
* that escaped, not a production endpoint.
|
||||
*/
|
||||
function isNonPublicHost(hostname: string): boolean {
|
||||
const host = hostname.toLowerCase().replace(/^\[|\]$/gu, "");
|
||||
if (
|
||||
host === "localhost" ||
|
||||
host.endsWith(".localhost") ||
|
||||
host === "::1" ||
|
||||
host === "0.0.0.0" ||
|
||||
host === "::"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const octets = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u.exec(host);
|
||||
if (!octets) return false;
|
||||
const [first, second] = [Number(octets[1]), Number(octets[2])];
|
||||
return (
|
||||
first === 127 ||
|
||||
first === 10 ||
|
||||
(first === 192 && second === 168) ||
|
||||
(first === 172 && second >= 16 && second <= 31) ||
|
||||
(first === 169 && second === 254)
|
||||
);
|
||||
}
|
||||
@@ -47,6 +47,11 @@ export const ENV_REGISTRY = Object.freeze({
|
||||
RELEASE_MANIFEST_URL: runtime("public", true, "/release-manifest.json"),
|
||||
// §3.5: overrides may only disable an installed capability, never enable one.
|
||||
CAPABILITY_OVERRIDES: runtime("public", false, null),
|
||||
// §3.5: likewise for features — subtractive, keyed by installed feature id.
|
||||
FEATURE_OVERRIDES: runtime("public", false, null),
|
||||
// §3.5: build-time narrowing of the product manifest. A feature left out
|
||||
// here is not imported by any registry and never reaches the bundle.
|
||||
VITE_PRODUCT_FEATURES: build("compile-time", false, null),
|
||||
});
|
||||
|
||||
function build(
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* §3.5 / §6.1. Which product features this build contains, and which of them a
|
||||
* deployment is allowed to switch off.
|
||||
*
|
||||
* Two different questions, deliberately answered by two different inputs:
|
||||
*
|
||||
* - **Installed** is a build-time decision. `VITE_PRODUCT_FEATURES` selects
|
||||
* from the features this source tree declares; a feature left out contributes
|
||||
* no route, no operation, no schema, no codec and no adapter, so nothing can
|
||||
* reach it. It does *not* shrink the bundle: a static import cannot be undone
|
||||
* by a value, and building the import graph from a configuration string is
|
||||
* exactly what §3.5 forbids. Physical removal is FE-GATE-020's job — delete
|
||||
* the feature directory and rebuild, which that gate proves still works.
|
||||
* - **Active** is a runtime decision. `FEATURE_OVERRIDES` in the runtime config
|
||||
* may take an installed feature out of service without a rebuild.
|
||||
*
|
||||
* Both directions are subtractive, and that is the invariant this module
|
||||
* exists to hold: neither input can ever turn on a feature whose source is
|
||||
* absent. A configuration document that could name a feature into existence
|
||||
* would be a configuration document that chooses which code runs, and no
|
||||
* dynamic import path is ever built from one.
|
||||
*/
|
||||
|
||||
export type ProductFeatureOverride = "DEFAULT" | "DISABLED";
|
||||
|
||||
export type ProductFeatureState =
|
||||
/** Compiled in and not disabled: the feature serves traffic. */
|
||||
| "ACTIVE"
|
||||
/** Compiled in, switched off by the runtime document. */
|
||||
| "DISABLED_BY_CONFIG"
|
||||
/** Not selected at build time; not in the bundle. */
|
||||
| "NOT_INSTALLED";
|
||||
|
||||
export type ProductFeatureOverrideMap = Readonly<
|
||||
Record<string, ProductFeatureOverride>
|
||||
>;
|
||||
|
||||
export type ProductFeatureStatus = Readonly<{
|
||||
featureId: string;
|
||||
state: ProductFeatureState;
|
||||
}>;
|
||||
|
||||
/** The shape every feature contract exposes to the manifest. */
|
||||
export type SelectableProductFeature = Readonly<{ featureId: string }>;
|
||||
|
||||
/**
|
||||
* The explicit "no product features" selection.
|
||||
*
|
||||
* A blank value cannot mean it: an unset CI variable expands to a blank string
|
||||
* far too easily, and a build that silently shipped no features would be a very
|
||||
* expensive way to learn that. Selecting nothing has to be something you typed.
|
||||
*/
|
||||
export const NO_PRODUCT_FEATURES = "none";
|
||||
|
||||
/**
|
||||
* Applies the build-time selection to the features this source tree declares.
|
||||
*
|
||||
* An empty or absent declaration keeps everything, so an ordinary build needs
|
||||
* no environment at all. A declaration naming something that is not compiled is
|
||||
* refused rather than ignored: silently accepting it would let a deployment
|
||||
* believe it had enabled a feature that does not exist.
|
||||
*/
|
||||
export function selectCompiledProductFeatures<
|
||||
Feature extends SelectableProductFeature,
|
||||
>(
|
||||
compiled: readonly Feature[],
|
||||
declared: string | undefined,
|
||||
): readonly Feature[] {
|
||||
const compiledIds = compiled.map((feature) => feature.featureId);
|
||||
assertUniqueFeatureIds(compiledIds);
|
||||
if (declared === undefined || declared.trim() === "") {
|
||||
return Object.freeze([...compiled]);
|
||||
}
|
||||
if (declared.trim() === NO_PRODUCT_FEATURES) return Object.freeze([]);
|
||||
const requested = declared
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
if (requested.length === 0) {
|
||||
throw new Error(
|
||||
`VITE_PRODUCT_FEATURES is set to ${JSON.stringify(declared)}, which names ` +
|
||||
`no feature. Use "${NO_PRODUCT_FEATURES}" to select none, or leave it ` +
|
||||
`unset to keep ${compiledIds.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
const unknown = requested.filter((id) => !compiledIds.includes(id));
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(
|
||||
`VITE_PRODUCT_FEATURES names features this build does not contain: ${unknown.join(
|
||||
", ",
|
||||
)}. Selection can only remove from ${compiledIds.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
return Object.freeze(
|
||||
compiled.filter((feature) => requested.includes(feature.featureId)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The state of every feature the source tree declares, given what was compiled
|
||||
* and what the runtime document says.
|
||||
*
|
||||
* `compiledIds` is the full declared set rather than the installed one so a
|
||||
* build that dropped a feature still reports it as `NOT_INSTALLED` instead of
|
||||
* omitting it. An operator looking at the platform overview needs to see the
|
||||
* difference between "off" and "never heard of it".
|
||||
*/
|
||||
export function resolveProductFeatures(
|
||||
compiledIds: readonly string[],
|
||||
installedIds: readonly string[],
|
||||
overrides: ProductFeatureOverrideMap = {},
|
||||
): readonly ProductFeatureStatus[] {
|
||||
assertUniqueFeatureIds(compiledIds);
|
||||
return Object.freeze(
|
||||
[...compiledIds].sort().map((featureId) =>
|
||||
Object.freeze({
|
||||
featureId,
|
||||
state: !installedIds.includes(featureId)
|
||||
? ("NOT_INSTALLED" as const)
|
||||
: overrides[featureId] === "DISABLED"
|
||||
? ("DISABLED_BY_CONFIG" as const)
|
||||
: ("ACTIVE" as const),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** The feature ids serving traffic right now. */
|
||||
export function activeProductFeatureIds(
|
||||
statuses: readonly ProductFeatureStatus[],
|
||||
): readonly string[] {
|
||||
return Object.freeze(
|
||||
statuses
|
||||
.filter((status) => status.state === "ACTIVE")
|
||||
.map((status) => status.featureId),
|
||||
);
|
||||
}
|
||||
|
||||
function assertUniqueFeatureIds(ids: readonly string[]): void {
|
||||
if (new Set(ids).size !== ids.length) {
|
||||
throw new Error(`duplicate product feature id: ${ids.join(", ")}`);
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,23 @@ export const capabilityOverrideArtifactSchema = z
|
||||
OFFLINE_COMMANDS: "DEFAULT",
|
||||
});
|
||||
|
||||
/**
|
||||
* §3.5 / §6.1. A runtime switch that can take an installed feature out of
|
||||
* service without a rebuild.
|
||||
*
|
||||
* Values are `DEFAULT | DISABLED` for the same reason `CAPABILITY_OVERRIDES`
|
||||
* is: a configuration document may subtract from what the build installed and
|
||||
* may never add to it. Keys are feature ids; naming a feature this build does
|
||||
* not contain is inert rather than an error, so a shared configuration
|
||||
* document can cover several builds.
|
||||
*/
|
||||
export const featureOverrideArtifactSchema = z
|
||||
.record(
|
||||
z.string().regex(/^[a-z][a-z0-9-]{0,63}$/u, "feature id is invalid"),
|
||||
z.enum(["DEFAULT", "DISABLED"]),
|
||||
)
|
||||
.default({});
|
||||
|
||||
type RuntimeConfigArtifactDraft = Readonly<{
|
||||
APP_ENV: "local" | "development" | "staging" | "production";
|
||||
API_BASE_URL: string;
|
||||
@@ -138,6 +155,7 @@ export const runtimeConfigV2ArtifactSchema = z
|
||||
...runtimeConfigArtifactFields,
|
||||
CONFIG_SCHEMA_VERSION: z.literal("2.0"),
|
||||
CAPABILITY_OVERRIDES: capabilityOverrideArtifactSchema,
|
||||
FEATURE_OVERRIDES: featureOverrideArtifactSchema,
|
||||
})
|
||||
.strict()
|
||||
.superRefine(runtimeConfigArtifactInvariants);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { verifyCompatibilityTuple } from "../application/policies/compatibility.ts";
|
||||
import { verifyCompatibilityTuple } from "./compatibility.ts";
|
||||
|
||||
export const RELEASE_TOKEN_REGISTRY = Object.freeze({
|
||||
appVersion: token("appVersion", "manifest", "human release label"),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { AppFailure } from "./errors.ts";
|
||||
|
||||
/**
|
||||
* The single success/failure carrier used across application input boundaries.
|
||||
* Adapters map technology-specific errors to an application failure before
|
||||
* constructing this value.
|
||||
*
|
||||
* It lives in `src/contracts` because it is shared vocabulary rather than
|
||||
* application behaviour: contracts modules describe results too, and reaching
|
||||
* up into `src/application` for the shape made the dependency between the two
|
||||
* packages point both ways. `src/application/result.ts` re-exports it.
|
||||
*/
|
||||
export type Result<Value, Failure = AppFailure> =
|
||||
| Readonly<{ ok: true; value: Value }>
|
||||
| Readonly<{ ok: false; error: Failure }>;
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Result } from "../application/result.ts";
|
||||
import type { Result } from "./result.ts";
|
||||
import type { QueryInvalidationTopic } from "./query-invalidation.ts";
|
||||
import {
|
||||
createBoundQueryKey,
|
||||
|
||||
Reference in New Issue
Block a user