diff --git a/.changeset/g5-materialize-build.md b/.changeset/g5-materialize-build.md
new file mode 100644
index 000000000..37a4768c0
--- /dev/null
+++ b/.changeset/g5-materialize-build.md
@@ -0,0 +1,11 @@
+---
+"@linchkit/core": minor
+---
+
+G5 Phase 2 + 3 — proposal code materialization + build-gate (engine).
+
+- **Materializer (P3):** `materializeProposalChanges({ proposal, provider, qualityGate?, maxRetries?, context? })` generates TypeScript source for the irreducibly-code parts of a proposal (action / event / flow logic bodies) via a `CodeGenerationProvider`, attaching it to each change as the new optional `ProposalChange.generatedSource`. Declarative targets (entity / rule / view / state / overlay) and deletes are skipped. Generate → quality-gate → retry-with-feedback (default 3 attempts). Returns a COPY — never mutates the input, never writes files, runs code, approves, or graduates.
+- **Build gate (P2):** `checkSourceSyntax()` / `createSyntaxQualityGate()` validate generated source SYNTACTICALLY via Bun's transpiler (no project-aware type resolution — that would false-positive on project symbol references; left to the graduation PR's CI). `validatePhase2()` runs this over a proposal's `generatedSource` and is now wired into `validateProposal` (previously a skipped stub). Warn-only by default; `ValidationContext.strictGeneratedBuild` escalates to blocking. All-declarative proposals still see Phase 2 "skipped" — existing callers are unaffected.
+- New `@linchkit/core/server` exports: `materializeProposalChanges`, `isMaterializable`, `validatePhase2`, `checkSourceSyntax`, `createSyntaxQualityGate` (+ types).
+
+SAFETY: candidate source only — it flows through validation (Phase 2) and double human review (draft + graduation PR) before it can land. "AI never modifies production directly." Not yet wired into a live HTTP/draft path (a thin follow-up).
diff --git a/packages/core/__tests__/barrel-public-surface.test.ts b/packages/core/__tests__/barrel-public-surface.test.ts
index d4f39dbdc..0dc11f9ba 100644
--- a/packages/core/__tests__/barrel-public-surface.test.ts
+++ b/packages/core/__tests__/barrel-public-surface.test.ts
@@ -269,6 +269,7 @@ const EXPECTED_SERVER_EXPORTS = [
"checkActionPermission",
"checkConnection",
"checkCoreCompatibility",
+ "checkSourceSyntax",
"clearDoctorChecks",
"closeDatabase",
"collectRules",
@@ -320,6 +321,7 @@ const EXPECTED_SERVER_EXPORTS = [
"createStateMachine",
"createStructuredLogger",
"createSyncFlowEngine",
+ "createSyntaxQualityGate",
"createTenantAwareDataProvider",
"createTenantIsolationMiddleware",
"createTestLogSink",
@@ -356,11 +358,13 @@ const EXPECTED_SERVER_EXPORTS = [
"getDoctorChecks",
"getObservability",
"getTraceDepth",
+ "isMaterializable",
"linchkitSchema",
"livenessCheck",
"maskRecord",
"maskRecords",
"maskValue",
+ "materializeProposalChanges",
"mergeCapabilityPool",
"noopAITraceSink",
"noopMeter",
@@ -396,6 +400,7 @@ const EXPECTED_SERVER_EXPORTS = [
"validateAIOutput",
"validateAIProposal",
"validatePhase1",
+ "validatePhase2",
"validatePhase3",
"validateProposal",
"validateProposalExtended",
diff --git a/packages/core/__tests__/proposal-file-writer.test.ts b/packages/core/__tests__/proposal-file-writer.test.ts
index 719e837b2..66b2db33f 100644
--- a/packages/core/__tests__/proposal-file-writer.test.ts
+++ b/packages/core/__tests__/proposal-file-writer.test.ts
@@ -138,6 +138,57 @@ describe("ProposalFileWriter.writeApprovedProposal", () => {
expect(contents).toContain("Capability:");
});
+ it("writes AI-materialized generatedSource verbatim, not the codegen output (G5)", async () => {
+ const writer = new ProposalFileWriter({ rootDir: tmpDir });
+ const GENERATED = [
+ 'import { defineAction } from "@linchkit/core";',
+ "export const do_thing = defineAction({",
+ ' name: "do_thing",',
+ " handler: async () => ({ ok: true }),",
+ "});",
+ "",
+ ].join("\n");
+ const proposal = makeApprovedProposal({
+ changes: [
+ {
+ target: "action",
+ operation: "create",
+ name: "do_thing",
+ definition: { name: "do_thing" } as never,
+ generatedSource: GENERATED,
+ },
+ ],
+ });
+
+ const [written] = await writer.writeApprovedProposal(proposal);
+ expect(written).toBeDefined();
+ const contents = await readFile(written as string, "utf8");
+ // The materialized handler body lands verbatim — the deterministic codegen
+ // (which would only scaffold a declarative wrapper) is NOT used.
+ expect(contents).toBe(GENERATED);
+ expect(contents).toContain("handler: async () => ({ ok: true })");
+ });
+
+ it("falls back to codegen for an empty generatedSource (never writes a blank file)", async () => {
+ const writer = new ProposalFileWriter({ rootDir: tmpDir });
+ const proposal = makeApprovedProposal({
+ changes: [
+ {
+ target: "action",
+ operation: "create",
+ name: "do_thing",
+ definition: { name: "do_thing" } as never,
+ generatedSource: " \n", // whitespace-only → treated as absent
+ },
+ ],
+ });
+
+ const [written] = await writer.writeApprovedProposal(proposal);
+ const contents = await readFile(written as string, "utf8");
+ expect(contents.trim().length).toBeGreaterThan(0); // not blank
+ expect(contents).toContain("defineAction("); // deterministic scaffold used
+ });
+
it("writes multiple changes in one proposal", async () => {
const writer = new ProposalFileWriter({ rootDir: tmpDir });
const proposal = makeApprovedProposal({
diff --git a/packages/core/src/__tests__/engine/code-quality-gate.test.ts b/packages/core/src/__tests__/engine/code-quality-gate.test.ts
new file mode 100644
index 000000000..2c4e4797a
--- /dev/null
+++ b/packages/core/src/__tests__/engine/code-quality-gate.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, test } from "bun:test";
+import { checkSourceSyntax, createSyntaxQualityGate } from "../../engine/code-quality-gate";
+
+describe("checkSourceSyntax", () => {
+ test("returns no errors for syntactically valid TypeScript", () => {
+ const errors = checkSourceSyntax(
+ "export const x: number = 1;\nexport function f(a: string): string { return a; }",
+ );
+ expect(errors).toEqual([]);
+ });
+
+ test("reports a syntax error for malformed source", () => {
+ const errors = checkSourceSyntax("export const a = {\n"); // unbalanced brace
+ expect(errors.length).toBeGreaterThan(0);
+ });
+
+ test("flags empty / whitespace-only source", () => {
+ expect(checkSourceSyntax("")).toEqual(["Generated source is empty."]);
+ expect(checkSourceSyntax(" \n\t")).toEqual(["Generated source is empty."]);
+ });
+
+ test("accepts tsx via filename loader", () => {
+ const errors = checkSourceSyntax("export const E = () =>
hi
;", "comp.tsx");
+ expect(errors).toEqual([]);
+ });
+});
+
+describe("createSyntaxQualityGate", () => {
+ test("aggregates errors across files, prefixed with the path", async () => {
+ const gate = createSyntaxQualityGate();
+ const errors = await gate.check({
+ "ok.ts": "export const x = 1;",
+ "bad.ts": "export const a = {",
+ });
+ expect(errors.some((e) => e.startsWith("bad.ts: "))).toBe(true);
+ expect(errors.some((e) => e.startsWith("ok.ts: "))).toBe(false);
+ });
+
+ test("returns no errors when every file is valid", async () => {
+ const gate = createSyntaxQualityGate();
+ const errors = await gate.check({
+ "a.ts": "export const a = 1;",
+ "b.ts": "export const b = 2;",
+ });
+ expect(errors).toEqual([]);
+ });
+});
diff --git a/packages/core/src/__tests__/engine/proposal-materializer.test.ts b/packages/core/src/__tests__/engine/proposal-materializer.test.ts
new file mode 100644
index 000000000..5588e7404
--- /dev/null
+++ b/packages/core/src/__tests__/engine/proposal-materializer.test.ts
@@ -0,0 +1,219 @@
+import { describe, expect, test } from "bun:test";
+import type { CodeGenerationProvider } from "../../ai/proposal-code-generator";
+import { createSyntaxQualityGate } from "../../engine/code-quality-gate";
+import { isMaterializable, materializeProposalChanges } from "../../engine/proposal-materializer";
+import type { ProposalChange, ProposalDefinition } from "../../types/proposal";
+
+function makeProposal(changes: ProposalChange[]): ProposalDefinition {
+ const now = new Date();
+ return {
+ id: "prop-mat-1",
+ title: "Add deduct_inventory action",
+ description: "When an order is approved, deduct inventory",
+ author: { type: "ai", id: "pattern-detector", name: "Pattern Detector" },
+ capability: "cap-demo",
+ changeType: "minor",
+ changes,
+ impact: {
+ schemasAffected: [],
+ actionsAffected: [],
+ rulesAffected: [],
+ dependentsAffected: [],
+ migrationRequired: false,
+ },
+ status: "draft",
+ createdAt: now,
+ updatedAt: now,
+ } as ProposalDefinition;
+}
+
+/** Fake provider returning a fixed sequence (sticks on the last entry). */
+function makeProvider(responses: string[]): {
+ provider: CodeGenerationProvider;
+ calls: Array<{ prompt: string; context?: string }>;
+} {
+ let i = 0;
+ const calls: Array<{ prompt: string; context?: string }> = [];
+ const provider: CodeGenerationProvider = {
+ async generateCode(prompt: string, context?: string): Promise {
+ calls.push({ prompt, context });
+ const r = responses[Math.min(i, responses.length - 1)] ?? "";
+ i += 1;
+ return r;
+ },
+ };
+ return { provider, calls };
+}
+
+const GOOD = "export const deduct_inventory = 1;";
+const BAD = "export const a = {"; // syntax error
+
+describe("isMaterializable", () => {
+ test("action create|update is materializable; declarative targets, flow, and delete are not", () => {
+ expect(isMaterializable({ target: "action", operation: "create", name: "a" })).toBe(true);
+ expect(isMaterializable({ target: "action", operation: "update", name: "a2" })).toBe(true);
+ // event is declarative (name + payload), flow has no defineFlow API yet.
+ expect(isMaterializable({ target: "event", operation: "update", name: "e" })).toBe(false);
+ expect(isMaterializable({ target: "flow", operation: "create", name: "f" })).toBe(false);
+ expect(isMaterializable({ target: "entity", operation: "create", name: "x" })).toBe(false);
+ expect(isMaterializable({ target: "rule", operation: "create", name: "r" })).toBe(false);
+ expect(isMaterializable({ target: "action", operation: "delete", name: "a" })).toBe(false);
+ });
+});
+
+describe("materializeProposalChanges", () => {
+ test("attaches generatedSource to a materializable change without mutating the input", async () => {
+ const input = makeProposal([
+ { target: "action", operation: "create", name: "deduct_inventory" },
+ ]);
+ const { provider } = makeProvider([GOOD]);
+
+ const result = await materializeProposalChanges({
+ proposal: input,
+ provider,
+ qualityGate: createSyntaxQualityGate(),
+ });
+
+ expect(result.allMaterialized).toBe(true);
+ expect(result.outcomes[0]?.status).toBe("materialized");
+ expect(result.proposal.changes[0]?.generatedSource).toBe(GOOD);
+ // input untouched
+ expect(input.changes[0]?.generatedSource).toBeUndefined();
+ });
+
+ test("skips declarative targets and delete operations", async () => {
+ const input = makeProposal([
+ { target: "rule", operation: "create", name: "late_fee" },
+ { target: "action", operation: "delete", name: "old_action" },
+ ]);
+ const { provider, calls } = makeProvider([GOOD]);
+
+ const result = await materializeProposalChanges({ proposal: input, provider });
+
+ expect(result.outcomes.every((o) => o.status === "skipped")).toBe(true);
+ expect(calls).toHaveLength(0); // provider never called
+ expect(result.proposal.changes.every((c) => c.generatedSource === undefined)).toBe(true);
+ });
+
+ test("retries on a gate failure and succeeds on a later attempt", async () => {
+ const input = makeProposal([
+ { target: "action", operation: "create", name: "deduct_inventory" },
+ ]);
+ const { provider } = makeProvider([BAD, GOOD]);
+
+ const result = await materializeProposalChanges({
+ proposal: input,
+ provider,
+ qualityGate: createSyntaxQualityGate(),
+ maxRetries: 3,
+ });
+
+ expect(result.outcomes[0]?.status).toBe("materialized");
+ expect(result.outcomes[0]?.attempts).toBe(2);
+ expect(result.proposal.changes[0]?.generatedSource).toBe(GOOD);
+ });
+
+ test("fails after exhausting retries when the gate never passes", async () => {
+ const input = makeProposal([
+ { target: "action", operation: "create", name: "deduct_inventory" },
+ ]);
+ const { provider } = makeProvider([BAD]);
+
+ const result = await materializeProposalChanges({
+ proposal: input,
+ provider,
+ qualityGate: createSyntaxQualityGate(),
+ maxRetries: 2,
+ });
+
+ expect(result.allMaterialized).toBe(false);
+ expect(result.outcomes[0]?.status).toBe("failed");
+ expect(result.outcomes[0]?.attempts).toBe(2);
+ expect((result.outcomes[0]?.errors ?? []).length).toBeGreaterThan(0);
+ expect(result.proposal.changes[0]?.generatedSource).toBeUndefined();
+ });
+
+ test("clears stale generatedSource when a re-materialization fails", async () => {
+ const input = makeProposal([
+ {
+ target: "action",
+ operation: "create",
+ name: "deduct_inventory",
+ generatedSource: "export const STALE = 1;",
+ },
+ ]);
+ const { provider } = makeProvider([BAD]); // every attempt fails the gate
+
+ const result = await materializeProposalChanges({
+ proposal: input,
+ provider,
+ qualityGate: createSyntaxQualityGate(),
+ maxRetries: 2,
+ });
+
+ expect(result.outcomes[0]?.status).toBe("failed");
+ // The stale source must NOT survive a failed re-materialization.
+ expect(result.proposal.changes[0]?.generatedSource).toBeUndefined();
+ });
+
+ test("strips a markdown code fence from the generated source", async () => {
+ const input = makeProposal([
+ { target: "action", operation: "create", name: "deduct_inventory" },
+ ]);
+ const { provider } = makeProvider(["```ts\nexport const deduct_inventory = 1;\n```"]);
+
+ const result = await materializeProposalChanges({
+ proposal: input,
+ provider,
+ qualityGate: createSyntaxQualityGate(),
+ });
+
+ expect(result.proposal.changes[0]?.generatedSource).toBe("export const deduct_inventory = 1;");
+ });
+
+ test("extracts a fenced code block wrapped in conversational prose", async () => {
+ const input = makeProposal([
+ { target: "action", operation: "create", name: "deduct_inventory" },
+ ]);
+ const { provider } = makeProvider([
+ "Sure! Here is the code:\n```ts\nexport const deduct_inventory = 1;\n```\nLet me know if you need changes.",
+ ]);
+
+ const result = await materializeProposalChanges({
+ proposal: input,
+ provider,
+ qualityGate: createSyntaxQualityGate(),
+ });
+
+ expect(result.outcomes[0]?.status).toBe("materialized");
+ expect(result.proposal.changes[0]?.generatedSource).toBe("export const deduct_inventory = 1;");
+ });
+
+ test("treats a non-finite maxRetries as the default instead of skipping generation", async () => {
+ const input = makeProposal([
+ { target: "action", operation: "create", name: "deduct_inventory" },
+ ]);
+ const { provider } = makeProvider([GOOD]);
+
+ const result = await materializeProposalChanges({
+ proposal: input,
+ provider,
+ qualityGate: createSyntaxQualityGate(),
+ maxRetries: Number.NaN,
+ });
+
+ expect(result.outcomes[0]?.status).toBe("materialized");
+ expect(result.outcomes[0]?.attempts).toBe(1);
+ });
+
+ test("forwards context to the provider as the system message", async () => {
+ const input = makeProposal([
+ { target: "action", operation: "create", name: "deduct_inventory" },
+ ]);
+ const { provider, calls } = makeProvider([GOOD]);
+
+ await materializeProposalChanges({ proposal: input, provider, context: "PROJECT CONVENTIONS" });
+
+ expect(calls[0]?.context).toBe("PROJECT CONVENTIONS");
+ });
+});
diff --git a/packages/core/src/__tests__/engine/validation-phase2.test.ts b/packages/core/src/__tests__/engine/validation-phase2.test.ts
new file mode 100644
index 000000000..03d528b5a
--- /dev/null
+++ b/packages/core/src/__tests__/engine/validation-phase2.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, test } from "bun:test";
+import { validatePhase2 } from "../../engine/validation-phase2";
+import type { ProposalChange } from "../../types/proposal";
+
+function change(overrides: Partial): ProposalChange {
+ return { target: "action", operation: "create", name: "do_thing", ...overrides };
+}
+
+describe("validatePhase2", () => {
+ test("skips when no change carries generatedSource", () => {
+ const result = validatePhase2({
+ changes: [change({}), change({ target: "rule", name: "r1" })],
+ });
+ expect(result.phase).toBe(2);
+ expect(result.status).toBe("skipped");
+ expect(result.errors).toEqual([]);
+ expect(result.warnings).toEqual([]);
+ });
+
+ test("an empty generatedSource is flagged, not skipped", () => {
+ const result = validatePhase2({
+ changes: [change({ name: "empty_action", generatedSource: "" })],
+ });
+ // Empty string is still a materialized-source change → not skipped.
+ expect(result.status).toBe("passed"); // warn-only default
+ expect(result.warnings.length).toBeGreaterThan(0);
+ expect(result.warnings[0]?.code).toBe("GENERATED_SOURCE_SYNTAX");
+ });
+
+ test("passes when generated source is syntactically valid", () => {
+ const result = validatePhase2({
+ changes: [change({ generatedSource: "export const x = 1;" })],
+ });
+ expect(result.status).toBe("passed");
+ expect(result.errors).toEqual([]);
+ expect(result.warnings).toEqual([]);
+ });
+
+ test("warn-only by default: bad source → warnings, status stays passed", () => {
+ const result = validatePhase2({
+ changes: [change({ name: "broken", generatedSource: "export const a = {" })],
+ });
+ expect(result.status).toBe("passed");
+ expect(result.errors).toEqual([]);
+ expect(result.warnings.length).toBeGreaterThan(0);
+ expect(result.warnings[0]?.code).toBe("GENERATED_SOURCE_SYNTAX");
+ expect(result.warnings[0]?.target).toBe("broken");
+ });
+
+ test("strictGeneratedBuild: bad source → errors, status failed", () => {
+ const result = validatePhase2({
+ changes: [change({ name: "broken", generatedSource: "export const a = {" })],
+ strictGeneratedBuild: true,
+ });
+ expect(result.status).toBe("failed");
+ expect(result.warnings).toEqual([]);
+ expect(result.errors.length).toBeGreaterThan(0);
+ expect(result.errors[0]?.code).toBe("GENERATED_SOURCE_SYNTAX");
+ });
+});
diff --git a/packages/core/src/engine/code-quality-gate.ts b/packages/core/src/engine/code-quality-gate.ts
new file mode 100644
index 000000000..814c0d1e8
--- /dev/null
+++ b/packages/core/src/engine/code-quality-gate.ts
@@ -0,0 +1,73 @@
+/**
+ * Code quality gate for AI-generated proposal source (G5 Phase 2).
+ *
+ * Validates the SYNTACTIC validity of generated TypeScript via Bun's transpiler.
+ * It does NOT resolve types or imports — there is no project context at proposal
+ * time, so a full project-aware typecheck would false-positive on every
+ * reference to a project symbol. Syntactic validation catches the most common
+ * LLM failure modes (truncated output, unbalanced braces, invalid tokens)
+ * without those false positives. A full project-aware build pass is a deliberate
+ * later enhancement.
+ *
+ * The check runs only on the Bun runtime (where generation happens). Off Bun
+ * (e.g. a non-Bun consumer of the compiled npm package) it degrades to a no-op
+ * so it can never throw or block — the worst case is "not syntax-checked here",
+ * which the graduation PR's CI still catches.
+ */
+
+import type { QualityGateRunner } from "../ai/proposal-code-generator";
+
+/** Minimal structural view of the Bun transpiler we rely on (avoids a hard @types/bun dep). */
+interface BunTranspilerLike {
+ transformSync(code: string): string;
+}
+interface BunLike {
+ Transpiler: new (options: { loader: "ts" | "tsx" }) => BunTranspilerLike;
+}
+
+/** Resolve Bun's transpiler at runtime, or `null` when not running on Bun. */
+function resolveBun(): BunLike | null {
+ const candidate = (globalThis as { Bun?: BunLike }).Bun;
+ return candidate && typeof candidate.Transpiler === "function" ? candidate : null;
+}
+
+/**
+ * Synchronously check a source string for SYNTAX errors.
+ *
+ * Returns a list of human-readable error messages (empty = syntactically valid,
+ * or skipped because Bun is unavailable). Never throws.
+ */
+export function checkSourceSyntax(source: string, filename = "generated.ts"): string[] {
+ if (typeof source !== "string" || source.trim().length === 0) {
+ return ["Generated source is empty."];
+ }
+ const bun = resolveBun();
+ if (!bun) return []; // not on Bun → cannot syntax-check here; degrade to no-op.
+
+ const loader: "ts" | "tsx" = filename.endsWith(".tsx") ? "tsx" : "ts";
+ try {
+ new bun.Transpiler({ loader }).transformSync(source);
+ return [];
+ } catch (err) {
+ return [err instanceof Error ? err.message : String(err)];
+ }
+}
+
+/**
+ * Build a {@link QualityGateRunner} (async) over {@link checkSourceSyntax}, for
+ * the proposal materializer's generate→check→retry loop. Each file is checked
+ * independently; messages are prefixed with the file path.
+ */
+export function createSyntaxQualityGate(): QualityGateRunner {
+ return {
+ check(files: Record): Promise {
+ const errors: string[] = [];
+ for (const [path, source] of Object.entries(files)) {
+ for (const message of checkSourceSyntax(source, path)) {
+ errors.push(`${path}: ${message}`);
+ }
+ }
+ return Promise.resolve(errors);
+ },
+ };
+}
diff --git a/packages/core/src/engine/proposal-file-writer.ts b/packages/core/src/engine/proposal-file-writer.ts
index 0d9897c3c..50a78ffa4 100644
--- a/packages/core/src/engine/proposal-file-writer.ts
+++ b/packages/core/src/engine/proposal-file-writer.ts
@@ -359,7 +359,20 @@ export class ProposalFileWriter {
}
const targetPath = await this.resolvePath(proposal, change, groupCache);
- const rawSource = this.codegen(proposal, change);
+ // Prefer AI-materialized source (G5) when it has real content: the
+ // materializer attaches `generatedSource` for code targets (e.g. an action
+ // handler body) that the deterministic codegen can only scaffold. Without
+ // this, a materialized proposal would graduate to a PR containing a stub,
+ // silently dropping the generated logic. An empty / whitespace
+ // generatedSource is treated as absent so a malformed materialization can
+ // never write a blank file — it falls back to the deterministic scaffold
+ // (Phase 2 also flags the empty source upstream). Declarative changes have
+ // no generatedSource → codegen.
+ const hasGeneratedSource =
+ typeof change.generatedSource === "string" && change.generatedSource.trim().length > 0;
+ const rawSource = hasGeneratedSource
+ ? (change.generatedSource as string)
+ : this.codegen(proposal, change);
const source = await this.maybeFormat(rawSource, targetPath, proposal);
await mkdir(dirname(targetPath), { recursive: true });
diff --git a/packages/core/src/engine/proposal-materializer.ts b/packages/core/src/engine/proposal-materializer.ts
new file mode 100644
index 000000000..9fa03ab05
--- /dev/null
+++ b/packages/core/src/engine/proposal-materializer.ts
@@ -0,0 +1,238 @@
+/**
+ * Proposal materializer (G5 Phase 3).
+ *
+ * Generates the irreducibly-code parts of a proposal — today the
+ * `ActionDefinition.handler` body, which a declarative `ChangeDefinition` cannot
+ * express — into TypeScript source, attaching it to each change as
+ * `generatedSource`. Declarative targets (entity / rule / view / state / event /
+ * overlay) are left to deterministic serialization and are skipped here. The
+ * materializable set is extensible (see {@link MATERIALIZABLE_TARGETS}).
+ *
+ * Pipeline per materializable change: build a prompt → `CodeGenerationProvider`
+ * generates source → (optional) `QualityGateRunner` build-checks it → on failure
+ * retry with the errors fed back, up to `maxRetries`. Generation is LAZY and
+ * caller-driven (no scheduler).
+ *
+ * SAFETY BOUNDARY ("AI never modifies production directly"): this returns a COPY
+ * of the proposal with candidate source attached. It NEVER writes files, runs
+ * the generated code, approves, or graduates anything. The attached source still
+ * flows through validation (Phase 2 build check) and double human review (draft
+ * review + graduation PR) before it can land.
+ */
+
+import type { CodeGenerationProvider, QualityGateRunner } from "../ai/proposal-code-generator";
+import type { ProposalChange, ProposalChangeTarget, ProposalDefinition } from "../types/proposal";
+
+/**
+ * Change targets whose logic must be AI-materialized (cannot be serialized
+ * declaratively). Today only `action` qualifies: `ActionDefinition.handler` is a
+ * real function body. Other current targets are declarative — `entity` / `rule`
+ * / `view` / `state` / `event` (an EventDefinition is name + payload, not logic)
+ * / `overlay` — and `flow` has no `defineFlow` API yet. Adding a target here (and
+ * to {@link TARGET_GUIDANCE}) is all it takes to extend scope when those land.
+ */
+const MATERIALIZABLE_TARGETS: ReadonlySet = new Set(["action"]);
+
+/** True when a change needs AI code generation (a code target, created or updated). */
+export function isMaterializable(change: ProposalChange): boolean {
+ return (
+ MATERIALIZABLE_TARGETS.has(change.target) &&
+ (change.operation === "create" || change.operation === "update")
+ );
+}
+
+export interface MaterializeProposalOptions {
+ /** Proposal to materialize. Not mutated — a copy is returned. */
+ proposal: ProposalDefinition;
+ /** AI code generation provider (e.g. cap-ai-provider's createCodeGenerationProvider). */
+ provider: CodeGenerationProvider;
+ /**
+ * Optional build/quality gate (Phase 2). When provided, generated source must
+ * pass it; a failure feeds the errors back into the next attempt. When absent,
+ * the first generation is accepted (validation still runs downstream).
+ */
+ qualityGate?: QualityGateRunner;
+ /** Max generation attempts per change (default 3, floored at 1). */
+ maxRetries?: number;
+ /**
+ * Project conventions / existing-entity context, passed as the system message
+ * to the provider on every attempt.
+ */
+ context?: string;
+}
+
+export interface MaterializeChangeOutcome {
+ changeName: string;
+ target: ProposalChangeTarget;
+ /** `materialized` = source attached; `skipped` = declarative target; `failed` = gate never passed. */
+ status: "materialized" | "skipped" | "failed";
+ /** Number of generation attempts made (0 for skipped). */
+ attempts: number;
+ /** Quality-gate errors from the final failed attempt (only when status="failed"). */
+ errors?: string[];
+}
+
+export interface MaterializeProposalResult {
+ /** A COPY of the input proposal with `generatedSource` attached to materialized changes. */
+ proposal: ProposalDefinition;
+ /** Per-change outcome, in input order. */
+ outcomes: MaterializeChangeOutcome[];
+ /** True when no materializable change failed (skipped/declarative changes don't count against this). */
+ allMaterialized: boolean;
+}
+
+/**
+ * Generate source for each materializable change in a proposal, returning a copy
+ * with `generatedSource` attached. Never mutates the input.
+ */
+export async function materializeProposalChanges(
+ options: MaterializeProposalOptions,
+): Promise {
+ const { proposal, provider, qualityGate, context } = options;
+ // Guard a non-finite maxRetries (NaN/Infinity) — Math.floor(NaN) would make the
+ // retry loop skip every attempt and report a spurious failure.
+ const requestedRetries = options.maxRetries;
+ const maxRetries = Math.max(
+ 1,
+ Math.floor(
+ typeof requestedRetries === "number" && Number.isFinite(requestedRetries)
+ ? requestedRetries
+ : 3,
+ ),
+ );
+
+ // Per-change shallow copies — we only ever set `generatedSource`.
+ const changes: ProposalChange[] = proposal.changes.map((c) => ({ ...c }));
+ const outcomes: MaterializeChangeOutcome[] = [];
+
+ for (const change of changes) {
+ if (!isMaterializable(change)) {
+ outcomes.push({
+ changeName: change.name,
+ target: change.target,
+ status: "skipped",
+ attempts: 0,
+ });
+ continue;
+ }
+
+ // Clear any pre-existing source up front so a failed (re-)materialization
+ // never leaves STALE code on the change — it is set again only on success.
+ change.generatedSource = undefined;
+
+ let lastErrors: string[] = [];
+ let materialized = false;
+ let attempt = 0;
+ for (attempt = 1; attempt <= maxRetries; attempt++) {
+ const prompt = buildChangePrompt(change, proposal, lastErrors);
+ const raw = await provider.generateCode(prompt, context);
+ const source = stripCodeFence(raw);
+
+ if (qualityGate) {
+ const errors = await qualityGate.check({ [`${change.name}.ts`]: source });
+ if (errors.length > 0) {
+ lastErrors = errors;
+ continue;
+ }
+ }
+
+ change.generatedSource = source;
+ materialized = true;
+ break;
+ }
+
+ outcomes.push(
+ materialized
+ ? {
+ changeName: change.name,
+ target: change.target,
+ status: "materialized",
+ attempts: attempt,
+ }
+ : {
+ changeName: change.name,
+ target: change.target,
+ status: "failed",
+ attempts: maxRetries,
+ errors: lastErrors,
+ },
+ );
+ }
+
+ const allMaterialized = outcomes.every((o) => o.status !== "failed");
+ return { proposal: { ...proposal, changes }, outcomes, allMaterialized };
+}
+
+// ── Prompt building ──────────────────────────────────────
+
+const TARGET_GUIDANCE: Record = {
+ action:
+ 'Use defineAction() from "@linchkit/core". Implement the `handler: (ctx) => Promise<...>` body fully.',
+};
+
+/** Build the per-change generation prompt (with retry feedback when present). */
+function buildChangePrompt(
+ change: ProposalChange,
+ proposal: ProposalDefinition,
+ previousErrors: string[],
+): string {
+ const lines: string[] = [];
+
+ if (previousErrors.length > 0) {
+ lines.push("# RETRY — the previous attempt failed the build/syntax check:");
+ for (const e of previousErrors) lines.push(`- ${e}`);
+ lines.push("Fix these and output a corrected, complete file.");
+ lines.push("");
+ }
+
+ lines.push("# Generate a complete LinchKit definition file");
+ lines.push("");
+ lines.push(`Target: ${change.target}`);
+ lines.push(`Operation: ${change.operation}`);
+ lines.push(`Name: ${change.name}`);
+ lines.push(`Capability: ${proposal.capability}`);
+ lines.push(`Proposal: ${proposal.title}`);
+ if (proposal.description) lines.push(`Description: ${proposal.description}`);
+ if (change.diff) lines.push(`Change summary: ${change.diff}`);
+
+ if (change.definition) {
+ lines.push("");
+ lines.push("Declarative definition (JSON) to implement:");
+ lines.push("```json");
+ lines.push(JSON.stringify(change.definition, null, 2));
+ lines.push("```");
+ }
+
+ lines.push("");
+ lines.push("Requirements:");
+ lines.push("- Output ONLY the TypeScript source for a single file — no markdown, no commentary.");
+ lines.push('- Include all necessary imports from "@linchkit/core".');
+ lines.push(`- ${TARGET_GUIDANCE[change.target] ?? "Implement the logic body fully."}`);
+ lines.push("- TypeScript strict mode; never use the `any` type.");
+ lines.push("- Action naming: verb_noun. Entity naming: snake_case.");
+
+ return lines.join("\n");
+}
+
+/**
+ * Extract TypeScript source from a model response. Despite the "output only
+ * source" instruction, models sometimes wrap the code in a markdown fence or
+ * surround that fence with conversational prose ("Sure! Here is the code: ...").
+ * Handles all three shapes — bare source, a fenced block, or a fenced block
+ * embedded in prose — and returns "" for a non-string input rather than throwing.
+ */
+function stripCodeFence(raw: unknown): string {
+ if (typeof raw !== "string") return "";
+ const trimmed = raw.trim();
+ // A fenced block anywhere (even wrapped in prose) — take the first block's body.
+ const fenced = trimmed.match(/```[a-zA-Z]*\r?\n([\s\S]*?)```/);
+ if (fenced) return (fenced[1] ?? "").trim();
+ // An opening fence with no closing fence — drop the opener (and a dangling one).
+ if (trimmed.startsWith("```")) {
+ return trimmed
+ .replace(/^```[a-zA-Z]*\r?\n?/, "")
+ .replace(/\r?\n?```$/, "")
+ .trim();
+ }
+ return trimmed;
+}
diff --git a/packages/core/src/engine/validation-engine.ts b/packages/core/src/engine/validation-engine.ts
index 51cd9ebf5..9ad4a3e0e 100644
--- a/packages/core/src/engine/validation-engine.ts
+++ b/packages/core/src/engine/validation-engine.ts
@@ -25,6 +25,7 @@ import type {
} from "../types/proposal";
import type { RuleDefinition } from "../types/rule";
import type { StateDefinition } from "../types/state";
+import { validatePhase2 } from "./validation-phase2";
import { validatePhase3 } from "./validation-phase3";
// ── Valid field types ────────────────────────────────────
@@ -70,6 +71,13 @@ export interface ValidationContext {
* Mirrors the `strictValidation` env-feature pattern.
*/
strictCompatibility?: boolean;
+ /**
+ * Escalate Phase 2 (build/syntax) findings on AI-materialized `generatedSource`
+ * from WARN to BLOCK. Default (false / undefined) → Phase 2 is warn-only and
+ * does NOT affect `passed`. When no change carries generated source, Phase 2 is
+ * skipped regardless. Mirrors the `strictCompatibility` pattern.
+ */
+ strictGeneratedBuild?: boolean;
}
// ── Phase 1: Static checks ──────────────────────────────
@@ -653,14 +661,14 @@ export function validateProposal(options: {
// Phase 1: Static checks
const phase1 = validatePhase1({ changes: proposal.changes, context });
- // Phase 2: Skipped for M1 (build check)
- const phase2: PhaseResult = {
- phase: 2,
- status: "skipped",
- errors: [],
- warnings: [],
- duration: 0,
- };
+ // Phase 2: Build (syntax) check of any AI-materialized `generatedSource` on the
+ // changes (G5). Warn-only by default; gated to BLOCK via strictGeneratedBuild.
+ // When no change carries generated source, validatePhase2 returns "skipped" —
+ // existing all-declarative callers are unaffected.
+ const phase2 = validatePhase2({
+ changes: proposal.changes,
+ strictGeneratedBuild: context?.strictGeneratedBuild,
+ });
// Phase 3: Compatibility (breaking-reference) check (Spec 09 §4.5).
// Warn-only by default; gated to BLOCK via context.strictCompatibility. When
diff --git a/packages/core/src/engine/validation-phase2.ts b/packages/core/src/engine/validation-phase2.ts
new file mode 100644
index 000000000..c3ec3ed59
--- /dev/null
+++ b/packages/core/src/engine/validation-phase2.ts
@@ -0,0 +1,90 @@
+/**
+ * Validation Phase 2 — Build (syntax) check of AI-materialized source (G5).
+ *
+ * Phase 2 inspects any `generatedSource` attached to a proposal's changes (by the
+ * proposal materializer) and reports SYNTACTIC build failures. Declarative
+ * changes carry no generated source, so for an all-declarative proposal Phase 2
+ * degrades to "skipped" — existing callers are unaffected.
+ *
+ * Severity / gating mirrors Phase 3 (low-regret):
+ * - DEFAULT: WARN-ONLY. Findings are `warnings`; `passed` is unaffected.
+ * - GATED: when `strictGeneratedBuild` is true, findings become `errors`
+ * (status "failed" → proposal `passed` = false → blocks).
+ *
+ * Scope: SYNTAX only (see {@link checkSourceSyntax}). Project-aware type
+ * resolution is out of scope here (no project context at proposal time) and is
+ * left to the graduation PR's CI / a later build-time pass.
+ */
+
+import type {
+ PhaseResult,
+ ProposalChange,
+ ValidationError,
+ ValidationWarning,
+} from "../types/proposal";
+import { checkSourceSyntax } from "./code-quality-gate";
+
+// ── Options ──────────────────────────────────────────────
+
+export interface ValidatePhase2Options {
+ /** The proposal's changes to inspect for materialized `generatedSource`. */
+ changes: ProposalChange[];
+ /**
+ * When true, generated-source syntax findings become ERRORS (blocking).
+ * Default (false / undefined) → findings are WARNINGS only and do not affect
+ * `passed`. Mirrors the `strictCompatibility` gating of Phase 3.
+ */
+ strictGeneratedBuild?: boolean;
+}
+
+// ── Entry point ──────────────────────────────────────────
+
+/**
+ * Run Phase 2 (build/syntax) validation on a proposal's changes.
+ *
+ * Returns a PhaseResult:
+ * - no change carries `generatedSource` → status "skipped" (no findings)
+ * - findings + strictGeneratedBuild=false → status "passed" with `warnings`
+ * - findings + strictGeneratedBuild=true → status "failed" with `errors`
+ */
+export function validatePhase2(options: ValidatePhase2Options): PhaseResult {
+ const { changes, strictGeneratedBuild = false } = options;
+ const start = Date.now();
+
+ // Include EVERY change carrying a string `generatedSource`, even an empty one:
+ // an empty / whitespace materialization is itself a finding (checkSourceSyntax
+ // flags it) and must not bypass Phase 2 by being filtered out here. Changes
+ // with no generatedSource (undefined) are declarative → nothing to build.
+ const withSource = changes.filter((c) => typeof c.generatedSource === "string");
+
+ // Nothing materialized → no build surface → skipped (back-compat).
+ if (withSource.length === 0) {
+ return { phase: 2, status: "skipped", errors: [], warnings: [], duration: Date.now() - start };
+ }
+
+ const findings: Array<{ code: string; message: string; target?: string }> = [];
+ for (const change of withSource) {
+ const messages = checkSourceSyntax(change.generatedSource as string, `${change.name}.ts`);
+ for (const message of messages) {
+ findings.push({
+ code: "GENERATED_SOURCE_SYNTAX",
+ message: `Generated source for ${change.target} "${change.name}" has a syntax error: ${message}`,
+ target: change.name,
+ });
+ }
+ }
+
+ const errors: ValidationError[] = [];
+ const warnings: ValidationWarning[] = [];
+ if (strictGeneratedBuild) {
+ for (const f of findings) errors.push(f);
+ } else {
+ for (const f of findings) warnings.push(f);
+ }
+
+ // Warn-only by default: status stays "passed" even with warnings — `passed` is
+ // only dragged false when strictGeneratedBuild escalated findings to errors.
+ const status: PhaseResult["status"] = errors.length === 0 ? "passed" : "failed";
+
+ return { phase: 2, status, errors, warnings, duration: Date.now() - start };
+}
diff --git a/packages/core/src/exports/server/engines.ts b/packages/core/src/exports/server/engines.ts
index 27cc7aa86..4546ebd39 100644
--- a/packages/core/src/exports/server/engines.ts
+++ b/packages/core/src/exports/server/engines.ts
@@ -30,6 +30,8 @@ export {
executeBatch,
MAX_BATCH_SIZE,
} from "../../engine/batch-action-engine";
+// G5 code materialization (Phase 2 build gate + Phase 3 materializer).
+export { checkSourceSyntax, createSyntaxQualityGate } from "../../engine/code-quality-gate";
export {
type CommandBatchExecuteOptions,
type CommandContext,
@@ -125,6 +127,13 @@ export {
type ProposalGitCommitterRunResult,
type ProposalGitRunner,
} from "../../engine/proposal-git-committer";
+export {
+ isMaterializable,
+ type MaterializeChangeOutcome,
+ type MaterializeProposalOptions,
+ type MaterializeProposalResult,
+ materializeProposalChanges,
+} from "../../engine/proposal-materializer";
export {
createProposalOutcomeRecorder,
type ProposalOutcomePayload,
@@ -161,6 +170,10 @@ export {
validatePhase1,
validateProposal,
} from "../../engine/validation-engine";
+export {
+ type ValidatePhase2Options,
+ validatePhase2,
+} from "../../engine/validation-phase2";
export {
type ValidatePhase3Options,
validatePhase3,
diff --git a/packages/core/src/types/proposal.ts b/packages/core/src/types/proposal.ts
index 7144485eb..6995ef0ea 100644
--- a/packages/core/src/types/proposal.ts
+++ b/packages/core/src/types/proposal.ts
@@ -109,6 +109,18 @@ export interface ProposalChange {
* a rollback can execute. This field NEVER triggers auto-execution.
*/
revertSha?: string;
+ /**
+ * AI-materialized TypeScript source for this change (G5 Phase 3).
+ *
+ * Only set for code targets whose logic body cannot be expressed declaratively
+ * in `definition` — today that is an `action` (its `handler` function).
+ * Produced by the proposal materializer, build-checked by validation Phase 2,
+ * and written verbatim by `ProposalFileWriter` at graduation (preferred over
+ * the deterministic codegen). Candidate source only — it never executes or
+ * lands without passing validation and double human review (draft + graduation
+ * PR).
+ */
+ generatedSource?: string;
}
// ── Impact analysis ──────────────────────────────────────