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:
DongHyeonka
2026-08-17 18:38:08 +09:00
co-authored by Claude Opus 5
100 changed files with 3310 additions and 443 deletions
+107
View File
@@ -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 -1
View File
@@ -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[];
+160
View File
@@ -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)
);
}
+5
View File
@@ -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(
+143
View File
@@ -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(", ")}`);
}
}
+18
View File
@@ -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 -1
View File
@@ -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"),
+15
View File
@@ -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 -1
View File
@@ -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,