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
+41 -13
View File
@@ -248,6 +248,7 @@ const artifactSchemaSchema = z.discriminatedUnion("kind", [
"provider-provenance",
"provider-verification",
"ci-contract-report",
"deployment-admission",
]),
})
.strict(),
@@ -443,7 +444,9 @@ export type LoadCiGateContractOptions = Readonly<{
}>;
const CANONICAL_GATE_SHAPE_SHA256 =
"492331de6d07926b1cdb569824c8feaa761eef76b9ac782c0404d40797383ef0";
// Template merge. Both sides carried a digest of their own gate set; neither
// describes the merged one. Recomputed from the merged config/ci/gates.json.
"5063586d799f51de94c0f0ddaf9b75e180825bba5051bc309550425013ea81ef";
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
const normalized = gates.map(
@@ -474,16 +477,17 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[]
(total, gate) => total + gate.commandIds.length,
0,
);
if (contract.gates.length !== 26) {
failures.push(`gate authority baseline must contain exactly 26 gates; received ${contract.gates.length}`);
if (contract.gates.length !== 27) {
failures.push(`gate authority baseline must contain exactly 27 gates; received ${contract.gates.length}`);
}
if (contract.commands.length !== 81 || commandReferenceCount !== 93) {
if (contract.commands.length !== 82 || commandReferenceCount !== 94) {
failures.push(
`command authority baseline must contain exactly 81 definitions and 93 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`,
`command authority baseline must contain exactly 82 definitions and 94 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`,
);
}
if (contract.artifacts.length !== 126) {
failures.push(`artifact authority baseline must contain exactly 126 artifacts; received ${contract.artifacts.length}`);
// Template merge. 126 product artifacts plus the two the template added.
if (contract.artifacts.length !== 128) {
failures.push(`artifact authority baseline must contain exactly 128 artifacts; received ${contract.artifacts.length}`);
}
if (contract.stages.length !== 5) {
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
@@ -512,7 +516,7 @@ export function parseCiGateContract(
.join("\n");
throw new TypeError(`CI gate contract invalid:\n${diagnostic}`);
}
if ((options.mode ?? "canonical") === "canonical") {
if ((options.mode ?? defaultCiContractMode()) === "canonical") {
const failures = canonicalAuthorityBaselineFailures(result.data);
if (failures.length > 0) {
throw new TypeError(`CI gate contract invalid:\n${failures.map((failure) => `root: ${failure}`).join("\n")}`);
@@ -521,11 +525,29 @@ export function parseCiGateContract(
return result.data;
}
/**
* A removal fixture runs the whole suite against a deliberately *reduced* CI
* contract: the removed capability's gates, commands and artifacts are pruned.
* Loading that contract in canonical mode re-imposes the full exact-count
* authority on it, so the fixture failed on the very reduction it exists to
* prove. `runRemovalFixturePnpm` marks those runs, and this is where the mark
* is honoured.
*/
export function defaultCiContractMode(): "canonical" | "removal-fixture" {
return process.env.CI_CONTRACT_MODE === "removal-fixture"
? "removal-fixture"
: "canonical";
}
export function isReducedCiContractRun(): boolean {
return defaultCiContractMode() === "removal-fixture";
}
export async function loadCiGateContract(
root = process.cwd(),
options: LoadCiGateContractOptions = {},
): Promise<CiGateContract> {
const mode = options.mode ?? "canonical";
const mode = options.mode ?? defaultCiContractMode();
const [rawContract, rawPackage] = await Promise.all([
readFile(path.join(root, "config/ci/gates.json"), "utf8"),
readFile(path.join(root, "package.json"), "utf8"),
@@ -798,11 +820,11 @@ function validateContractSemantics(
}
const expectedGateIds = Array.from(
{ length: 26 },
{ length: 27 },
(_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`,
);
if (JSON.stringify(contract.gates.map(({ id }) => id)) !== JSON.stringify(expectedGateIds)) {
issue("gate registry must contain FE-GATE-001..026 in canonical order");
issue("gate registry must contain FE-GATE-001..027 in canonical order");
}
const expectedStages: ReadonlyArray<readonly [string, string, readonly string[], readonly string[]]> = [
["merge", "MERGE_READY", [], PROMOTION_FORMULA.MERGE_READY],
@@ -873,7 +895,7 @@ function validateContractSemantics(
const expectedJobOwnership: Readonly<Record<string, readonly string[]>> = {
merge_gate: ["FE-GATE-001", "FE-GATE-002", "FE-GATE-003", "FE-GATE-004", "FE-GATE-005", "FE-GATE-006", "FE-GATE-007", "FE-GATE-008", "FE-GATE-009", "FE-GATE-010", "FE-GATE-011", "FE-GATE-013", "FE-GATE-020"],
release_gate: ["FE-GATE-012", "FE-GATE-014", "FE-GATE-019", "FE-GATE-026"],
immutable_build: ["FE-GATE-015"],
immutable_build: ["FE-GATE-015", "FE-GATE-027"],
vulnerability_provider: [],
provenance_provider: [],
promotion: [],
@@ -927,7 +949,13 @@ function validateContractSemantics(
const expectedEnvironmentBindings: Readonly<Record<string, readonly Readonly<{ name: string; value: string }> []>> = {
merge_gate: [],
release_gate: [{ name: "HOSTING_BASE_URL", value: "${{ vars.HOSTING_BASE_URL }}" }],
immutable_build: [],
immutable_build: [
// FE-GATE-027 admits the built artifact to a named environment, so both
// the profile it was built from and the destination it is claimed for are
// declared inputs. An absent RELEASE_TARGET is a refusal, not a default.
{ name: "APP_PROFILE", value: "${{ vars.APP_PROFILE }}" },
{ name: "RELEASE_TARGET", value: "${{ vars.RELEASE_TARGET }}" },
],
vulnerability_provider: [
{ name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" },
{ name: "CANDIDATE_ARCHIVE_PATH", value: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" },
+56 -3
View File
@@ -670,6 +670,27 @@ export const labPerformanceArtifactSchema = z
})
.strict();
/**
* FE-GATE-027. The record of which environment an artifact was admitted to, and
* every reason it was refused. Refusals are kept in the artifact so a rejected
* promotion leaves evidence rather than only a non-zero exit code.
*/
export const deploymentAdmissionArtifactSchema = z
.object({
schemaVersion: z.literal(1),
target: z.enum(["local", "development", "staging", "production"]),
appEnv: z.enum(["local", "development", "staging", "production"]),
authMode: z.enum(["external", "demo"]),
apiBaseUrl: nonEmptyString,
buildId: nonEmptyString.nullable(),
releaseId: nonEmptyString.nullable(),
status: z.enum(["ADMITTED", "REFUSED"]),
violations: z.array(
z.object({ field: nonEmptyString, reason: nonEmptyString }).strict(),
),
})
.strict();
export const releaseVerificationArtifactSchema = z
.object({
schemaVersion: z.literal(1),
@@ -1321,9 +1342,28 @@ export const documentationReviewArtifactSchema = z
reviewer: z.literal("wiki-diagram-reviewer"),
standard: z.literal("rules/diagram-standards.md v2"),
evidenceReport: z
.object({ repoPath: nonEmptyString, canonicalPath: nonEmptyString, canonicalSha256: sha256 })
.object({
repoPath: nonEmptyString,
upstreamCanonicalPath: nonEmptyString,
canonicalSha256: sha256,
})
.strict(),
reportDigestValid: z.boolean(),
/**
* The declared review scope, derived from the installed route registry
* rather than read off a sentence. Both scope documents claimed six routes
* while ten were registered.
*/
routeScope: z.array(
z
.object({
path: nonEmptyString,
missingRouteIds: z.array(nonEmptyString),
documented: z.boolean(),
})
.strict(),
).min(1),
routeScopeDocumented: z.boolean(),
results: z.array(
z
.object({
@@ -1350,8 +1390,21 @@ export const documentationReviewArtifactSchema = z
context.addIssue({ code: "custom", path: ["results", index, "passed"], message: "must agree with review evidence" });
}
});
if (artifact.passed !== (artifact.reportDigestValid && artifact.results.every(({ passed }) => passed))) {
context.addIssue({ code: "custom", path: ["passed"], message: "must agree with report digest and review results" });
artifact.routeScope.forEach((entry, index) => {
if (entry.documented !== (entry.missingRouteIds.length === 0)) {
context.addIssue({ code: "custom", path: ["routeScope", index, "documented"], message: "must agree with the missing route list" });
}
});
if (artifact.routeScopeDocumented !== artifact.routeScope.every(({ documented }) => documented)) {
context.addIssue({ code: "custom", path: ["routeScopeDocumented"], message: "must agree with every scope document" });
}
if (
artifact.passed !==
(artifact.reportDigestValid &&
artifact.routeScopeDocumented &&
artifact.results.every(({ passed }) => passed))
) {
context.addIssue({ code: "custom", path: ["passed"], message: "must agree with report digest, documented scope and review results" });
}
});