diff --git a/.changeset/validation-phase3-compatibility.md b/.changeset/validation-phase3-compatibility.md new file mode 100644 index 000000000..432fc0b07 --- /dev/null +++ b/.changeset/validation-phase3-compatibility.md @@ -0,0 +1,8 @@ +--- +"@linchkit/core": minor +"@linchkit/cap-adapter-server": minor +--- + +Implement proposal-validation Phase 3 (compatibility / breaking-reference checks, Spec 09 §4.5). `validatePhase3` inspects a proposal's delete/narrowing changes against the current meta-model (via the OntologyRegistry impact graph + field-reference scan) and flags breaking references: deleting a field still referenced by a view/rule, deleting an action/state with dependents, changing a field type, dropping a required field's default, or removing an enum value. + +Warn-only by default (does not affect `passed`); escalates to blocking errors when `strictCompatibility` is set. A new `features.strictCompatibility` env flag (default: true in production and staging, false in dev/test, mirroring `strictValidation`) wires this through `mountProposalAPI` so production and staging refuse proposals that break existing references while dev/test stays advisory. `ValidationContext` gains optional `ontology` + `strictCompatibility` fields; when absent, Phase 3 degrades to "skipped" so existing callers are unchanged. diff --git a/addons/adapter-server/cap-adapter-server/src/proposal-api.ts b/addons/adapter-server/cap-adapter-server/src/proposal-api.ts index c13d530cb..939fdcf9c 100644 --- a/addons/adapter-server/cap-adapter-server/src/proposal-api.ts +++ b/addons/adapter-server/cap-adapter-server/src/proposal-api.ts @@ -7,8 +7,12 @@ */ import { PatternDetector, type PatternInsight } from "@linchkit/cap-ai-provider"; -import type { ExecutionLogger, ProposalDefinition } from "@linchkit/core"; -import { createProposalEngine, type ProposalEngine } from "@linchkit/core/server"; +import type { ExecutionLogger, OntologyRegistry, ProposalDefinition } from "@linchkit/core"; +import { + createProposalEngine, + type ProposalEngine, + type ValidationContext, +} from "@linchkit/core/server"; // ── Types ───────────────────────────────────────────────── @@ -164,15 +168,51 @@ async function scanInsights(executionLogger: ExecutionLogger): Promise + typeof (v as { log?: unknown } | undefined)?.log === "function"; + const opts: MountProposalAPIOptions = isLogger(options) + ? { executionLogger: options } + : (options ?? {}); + const executionLogger = opts.executionLogger; + + // ValidationContext threaded into both submit sites so Phase 3 can run. + // Built once; when ontology is absent Phase 3 returns "skipped" (unchanged). + const validationContext: ValidationContext = { + ontology: opts.ontology, + strictCompatibility: opts.strictCompatibility, + }; + // Run initial pattern scan in background if execution logger is available if (executionLogger) { scanInsights(executionLogger).catch(() => { @@ -231,7 +271,7 @@ export function mountProposalAPI(app: any, executionLogger?: ExecutionLogger): v // If draft, auto-submit first if (proposal.status === "draft") { - proposalEngine.submitProposal({ proposalId: params.id }); + proposalEngine.submitProposal({ proposalId: params.id, context: validationContext }); } // Re-fetch to check validation result @@ -276,7 +316,7 @@ export function mountProposalAPI(app: any, executionLogger?: ExecutionLogger): v // If draft, auto-submit first if (proposal.status === "draft") { - proposalEngine.submitProposal({ proposalId: params.id }); + proposalEngine.submitProposal({ proposalId: params.id, context: validationContext }); } const refreshed = proposalEngine.getProposal(params.id); diff --git a/addons/adapter-server/cap-adapter-server/src/server.ts b/addons/adapter-server/cap-adapter-server/src/server.ts index b73f5dbbf..55caa9a64 100644 --- a/addons/adapter-server/cap-adapter-server/src/server.ts +++ b/addons/adapter-server/cap-adapter-server/src/server.ts @@ -442,7 +442,14 @@ export function createServer( mountDeployRoutes(app, opts.deployWebhookHandler); // ── Proposal / Evolution / AI Insights endpoints ────────────── - mountProposalAPI(app, executionLogger); + // Thread the ontology + the env compatibility policy so proposal validation + // Phase 3 (Spec 09 §4.5) can detect breaking references. strictCompatibility + // blocks breaking proposals in prod/staging; dev/test stay warn-only. + mountProposalAPI(app, { + executionLogger, + ontology: opts.ontologyRegistry, + strictCompatibility: environment.features.strictCompatibility, + }); // ── SSE Subscription endpoint (/api/subscribe) ──────────────── mountSubscriptionRoutes(app, opts); diff --git a/packages/core/__tests__/barrel-public-surface.test.ts b/packages/core/__tests__/barrel-public-surface.test.ts index 95595e308..c289a87b6 100644 --- a/packages/core/__tests__/barrel-public-surface.test.ts +++ b/packages/core/__tests__/barrel-public-surface.test.ts @@ -395,6 +395,7 @@ const EXPECTED_SERVER_EXPORTS = [ "validateAIOutput", "validateAIProposal", "validatePhase1", + "validatePhase3", "validateProposal", "validateProposalExtended", "validateRequiredEnvVars", diff --git a/packages/core/src/__tests__/engine/validation-phase3.test.ts b/packages/core/src/__tests__/engine/validation-phase3.test.ts new file mode 100644 index 000000000..59a580c75 --- /dev/null +++ b/packages/core/src/__tests__/engine/validation-phase3.test.ts @@ -0,0 +1,440 @@ +/** + * Validation Phase 3 (compatibility / breaking-reference) tests — Spec 09 §4.5 + * + * Covers both the standalone `validatePhase3` and its integration through + * `validateProposal`, asserting the warn-only default and the gated escalation + * to block via `strictCompatibility`. + */ + +import { describe, expect, test } from "bun:test"; +import { validateProposal } from "../../engine/validation-engine"; +import { validatePhase3 } from "../../engine/validation-phase3"; +import { createOntologyRegistry, type OntologyRegistryDeps } from "../../ontology"; +import type { ActionDefinition } from "../../types/action"; +import type { EntityDefinition } from "../../types/entity"; +import type { ProposalChange, ProposalDefinition } from "../../types/proposal"; +import type { RuleDefinition } from "../../types/rule"; +import type { StateDefinition } from "../../types/state"; +import type { ViewDefinition } from "../../types/view"; + +// ── Current (pre-change) meta-model ────────────────────── + +const orderEntity: EntityDefinition = { + name: "order", + label: "Order", + fields: { + title: { type: "string", label: "Title", required: true }, + amount: { type: "number", label: "Amount" }, + priority: { + type: "enum", + label: "Priority", + options: [{ value: "low" }, { value: "high" }], + }, + status: { type: "state", label: "Status", machine: "order_state" }, + note: { type: "string", label: "Note" }, + }, +}; + +const approveAction: ActionDefinition = { + name: "approve_order", + entity: "order", + label: "Approve Order", + policy: { mode: "sync", transaction: true }, +} as unknown as ActionDefinition; + +// Rule whose condition reads order.amount, triggered by the action. +const amountRule: RuleDefinition = { + name: "amount_check", + label: "Amount Check", + trigger: { action: "approve_order" }, + condition: { field: "amount", operator: "gt", value: 100 }, + effect: { type: "warn", message: "Large order" }, +}; + +// Rule triggered by a fieldChange on order.priority. +const priorityRule: RuleDefinition = { + name: "priority_watch", + label: "Priority Watch", + trigger: { fieldChange: { entity: "order", field: "priority" } }, + condition: { field: "priority", operator: "eq", value: "high" }, + effect: { type: "warn", message: "High priority" }, +}; + +// Rule whose effect triggers approve_order — creates a rule→triggers→action +// DAG edge so deleting the action has a detectable dependent. +const autoApproveRule: RuleDefinition = { + name: "auto_approve", + label: "Auto Approve", + trigger: { fieldChange: { entity: "order", field: "amount" } }, + condition: { field: "amount", operator: "lt", value: 10 }, + effect: { type: "execute_action", action: "approve_order" }, +}; + +const orderState: StateDefinition = { + name: "order_state", + entity: "order", + field: "status", + initial: "draft", + states: ["draft", "approved"], + transitions: [{ from: "draft", to: "approved", action: "approve_order" }], +}; + +const orderListView: ViewDefinition = { + name: "order_list", + entity: "order", + type: "list", + label: "Orders", + fields: [{ field: "title" }, { field: "amount" }, { field: "priority" }], +}; + +// ── OntologyRegistry test helpers ──────────────────────── + +function createMockEntityRegistry(entities: EntityDefinition[]) { + const map = new Map(entities.map((e) => [e.name, e])); + return { + getAll: () => entities, + get: (name: string) => map.get(name), + has: (name: string) => map.has(name), + }; +} + +function buildOntology(overrides?: Partial) { + const deps: OntologyRegistryDeps = { + schemas: createMockEntityRegistry([orderEntity]), + actions: { getAll: () => [approveAction] }, + rules: [amountRule, priorityRule, autoApproveRule], + states: [orderState], + views: [orderListView], + ...overrides, + }; + return createOntologyRegistry(deps); +} + +// ── Change builders ────────────────────────────────────── + +/** Entity delete change retaining only the given fields (the rest are removed). */ +function entityDeleteRetaining(fields: string[]): ProposalChange { + const def: EntityDefinition = { + name: "order", + fields: Object.fromEntries(fields.map((f) => [f, orderEntity.fields[f] ?? { type: "string" }])), + }; + return { target: "entity", operation: "delete", name: "order", definition: def }; +} + +/** Entity update change with the full new field set. */ +function entityUpdate(fields: EntityDefinition["fields"]): ProposalChange { + return { + target: "entity", + operation: "update", + name: "order", + definition: { name: "order", fields }, + }; +} + +function makeProposal(changes: ProposalChange[]): ProposalDefinition { + const now = new Date(); + return { + id: "p1", + title: "Test proposal", + description: "", + author: { type: "human", id: "u1", name: "Tester" }, + capability: "demo", + changeType: "major", + changes, + impact: { + schemasAffected: [], + actionsAffected: [], + rulesAffected: [], + dependentsAffected: [], + migrationRequired: false, + }, + status: "draft", + createdAt: now, + updatedAt: now, + }; +} + +// ── validatePhase3: skipped without ontology ───────────── + +describe("validatePhase3 — degradation", () => { + test("returns skipped when no ontology is provided", () => { + const result = validatePhase3({ + changes: [entityDeleteRetaining(["title"])], + }); + expect(result.phase).toBe(3); + expect(result.status).toBe("skipped"); + expect(result.errors).toHaveLength(0); + expect(result.warnings).toHaveLength(0); + }); + + test("does not throw when the entity is unknown to the ontology", () => { + const ontology = buildOntology(); + const result = validatePhase3({ + changes: [{ target: "entity", operation: "delete", name: "unknown_entity" }], + ontology, + }); + expect(result.status).toBe("passed"); + expect(result.warnings).toHaveLength(0); + expect(result.errors).toHaveLength(0); + }); +}); + +// ── Field-delete breakage ──────────────────────────────── + +describe("validatePhase3 — field deletion", () => { + test("deleting a field referenced by a view + rule warns by default", () => { + const ontology = buildOntology(); + // Drop `amount` (referenced by order_list view AND amount_check rule). + const result = validatePhase3({ + changes: [entityDeleteRetaining(["title", "priority", "status", "note"])], + ontology, + }); + expect(result.status).toBe("passed"); // warn-only + expect(result.errors).toHaveLength(0); + expect(result.warnings.length).toBeGreaterThanOrEqual(2); + const codes = result.warnings.map((w) => w.code); + expect(codes).toContain("BREAKING_FIELD_DELETE"); + const targets = result.warnings.map((w) => w.field); + expect(targets).toContain("amount"); + }); + + test("strictCompatibility escalates field-delete to a blocking error", () => { + const ontology = buildOntology(); + const result = validatePhase3({ + changes: [entityDeleteRetaining(["title", "priority", "status", "note"])], + ontology, + strictCompatibility: true, + }); + expect(result.status).toBe("failed"); + expect(result.warnings).toHaveLength(0); + expect(result.errors.length).toBeGreaterThanOrEqual(2); + expect(result.errors.map((e) => e.code)).toContain("BREAKING_FIELD_DELETE"); + }); + + test("deleting a field referenced by a fieldChange-trigger rule warns", () => { + const ontology = buildOntology(); + // Drop `priority` (referenced by priority_watch rule trigger + condition, + // and the order_list view). + const result = validatePhase3({ + changes: [entityDeleteRetaining(["title", "amount", "status", "note"])], + ontology, + }); + const priorityFindings = result.warnings.filter((w) => w.field === "priority"); + expect(priorityFindings.length).toBeGreaterThanOrEqual(1); + }); + + test("deleting an unreferenced field produces no findings", () => { + const ontology = buildOntology(); + // Drop `note` only (referenced by nothing). + const result = validatePhase3({ + changes: [entityDeleteRetaining(["title", "amount", "priority", "status"])], + ontology, + }); + expect(result.status).toBe("passed"); + expect(result.warnings).toHaveLength(0); + expect(result.errors).toHaveLength(0); + }); +}); + +// ── Element delete via impact graph ────────────────────── + +describe("validatePhase3 — action/state deletion", () => { + test("deleting an action that a rule + state depend on warns", () => { + const ontology = buildOntology(); + const result = validatePhase3({ + changes: [{ target: "action", operation: "delete", name: "approve_order" }], + ontology, + }); + expect(result.status).toBe("passed"); + expect(result.warnings.length).toBeGreaterThanOrEqual(1); + expect(result.warnings.map((w) => w.code)).toContain("BREAKING_ELEMENT_DELETE"); + }); + + test("deleting a state machine that nothing depends on produces no findings", () => { + const ontology = buildOntology(); + const result = validatePhase3({ + changes: [{ target: "state", operation: "delete", name: "order_state" }], + ontology, + }); + // Nothing in the DAG points TO state:order_state, so no breakage. + expect(result.status).toBe("passed"); + expect(result.warnings).toHaveLength(0); + }); + + test("deleting a whole entity flags entity-level dependents (not just fields)", () => { + const ontology = buildOntology(); + // Whole-entity delete: no surviving `definition`, so the entity itself — + // referenced by actions/views/states — must be flagged via the impact graph. + const result = validatePhase3({ + changes: [{ target: "entity", operation: "delete", name: "order" }], + ontology, + }); + expect(result.warnings.map((w) => w.code)).toContain("BREAKING_ELEMENT_DELETE"); + }); +}); + +// ── Update narrowing ───────────────────────────────────── + +describe("validatePhase3 — update narrowing", () => { + test("changing a field's type is breaking", () => { + const ontology = buildOntology(); + const result = validatePhase3({ + changes: [ + entityUpdate({ + ...orderEntity.fields, + amount: { type: "string", label: "Amount" }, // number → string + }), + ], + ontology, + }); + expect(result.warnings.map((w) => w.code)).toContain("BREAKING_FIELD_TYPE_CHANGE"); + expect(result.warnings.find((w) => w.field === "amount")).toBeDefined(); + }); + + test("removing an enum value is breaking", () => { + const ontology = buildOntology(); + const result = validatePhase3({ + changes: [ + entityUpdate({ + ...orderEntity.fields, + priority: { type: "enum", label: "Priority", options: [{ value: "low" }] }, // drop "high" + }), + ], + ontology, + }); + const codes = result.warnings.map((w) => w.code); + expect(codes).toContain("BREAKING_ENUM_VALUE_REMOVED"); + }); + + test("dropping the default of a previously-required field is breaking", () => { + const ontology = buildOntology({ + schemas: createMockEntityRegistry([ + { + name: "order", + fields: { + title: { type: "string", required: true, default: "untitled" }, + }, + }, + ]), + }); + const result = validatePhase3({ + changes: [entityUpdate({ title: { type: "string", required: true } })], + ontology, + }); + expect(result.warnings.map((w) => w.code)).toContain("BREAKING_REQUIRED_DEFAULT_DROP"); + }); + + test("making a required field optional and dropping its default is NOT breaking", () => { + const ontology = buildOntology({ + schemas: createMockEntityRegistry([ + { + name: "order", + fields: { + title: { type: "string", required: true, default: "untitled" }, + }, + }, + ]), + }); + // required+default → optional, default removed. This is a loosening change, + // so it must NOT be flagged (and must not block under strictCompatibility). + const result = validatePhase3({ + changes: [entityUpdate({ title: { type: "string", required: false } })], + ontology, + strictCompatibility: true, + }); + expect(result.errors.map((e) => e.code)).not.toContain("BREAKING_REQUIRED_DEFAULT_DROP"); + expect(result.warnings.map((w) => w.code)).not.toContain("BREAKING_REQUIRED_DEFAULT_DROP"); + }); + + test("adding an optional field is not breaking", () => { + const ontology = buildOntology(); + const result = validatePhase3({ + changes: [ + entityUpdate({ + ...orderEntity.fields, + tags: { type: "string", label: "Tags", required: false }, + }), + ], + ontology, + }); + expect(result.status).toBe("passed"); + expect(result.warnings).toHaveLength(0); + expect(result.errors).toHaveLength(0); + }); +}); + +// ── Integration via validateProposal ───────────────────── + +// A Phase-1-clean entity update that nonetheless narrows `amount` (number → +// string). Keeping Phase 1 green isolates Phase 3 as the only failing signal, +// so these tests assert Phase 3's effect on `passed` rather than Phase 1's. +function cleanBreakingAmountUpdate(): ProposalChange { + return entityUpdate({ + title: { type: "string", label: "Title", required: true, default: "untitled" }, + amount: { type: "string", label: "Amount" }, // breaking type change vs. number + priority: { + type: "enum", + label: "Priority", + options: [{ value: "low" }, { value: "high" }], + }, + status: { type: "state", label: "Status", machine: "order_state" }, + note: { type: "string", label: "Note" }, + }); +} + +describe("validateProposal — Phase 3 integration", () => { + test("breaking field type change warns but passed stays true by default", () => { + const ontology = buildOntology(); + const proposal = makeProposal([cleanBreakingAmountUpdate()]); + const result = validateProposal({ proposal, context: { ontology } }); + expect(result.passed).toBe(true); + const phase1 = result.phases.find((p) => p.phase === 1); + expect(phase1?.status).toBe("passed"); // confirm Phase 1 is not the signal + const phase3 = result.phases.find((p) => p.phase === 3); + expect(phase3?.status).toBe("passed"); + expect(phase3?.warnings.length).toBeGreaterThanOrEqual(1); + }); + + test("strictCompatibility blocks (passed becomes false)", () => { + const ontology = buildOntology(); + const proposal = makeProposal([cleanBreakingAmountUpdate()]); + const result = validateProposal({ + proposal, + context: { ontology, strictCompatibility: true }, + }); + expect(result.passed).toBe(false); + const phase1 = result.phases.find((p) => p.phase === 1); + expect(phase1?.status).toBe("passed"); // Phase 3 is the sole blocker + const phase3 = result.phases.find((p) => p.phase === 3); + expect(phase3?.status).toBe("failed"); + expect(phase3?.errors.length).toBeGreaterThanOrEqual(1); + }); + + test("without ontology in context, Phase 3 is skipped (unchanged behavior)", () => { + const proposal = makeProposal([cleanBreakingAmountUpdate()]); + const result = validateProposal({ proposal }); + expect(result.passed).toBe(true); + const phase3 = result.phases.find((p) => p.phase === 3); + expect(phase3?.status).toBe("skipped"); + expect(phase3?.warnings).toHaveLength(0); + expect(phase3?.errors).toHaveLength(0); + }); + + test("non-breaking proposal with empty context behaves as before", () => { + const proposal = makeProposal([ + { + target: "entity", + operation: "create", + name: "widget", + definition: { + name: "widget", + fields: { label: { type: "string", required: false } }, + }, + }, + ]); + const result = validateProposal({ proposal }); + expect(result.passed).toBe(true); + const phase3 = result.phases.find((p) => p.phase === 3); + expect(phase3?.status).toBe("skipped"); + }); +}); diff --git a/packages/core/src/deployment/environment.ts b/packages/core/src/deployment/environment.ts index 95b7b3c65..29133ff32 100644 --- a/packages/core/src/deployment/environment.ts +++ b/packages/core/src/deployment/environment.ts @@ -14,6 +14,13 @@ export interface EnvironmentFeatureFlags { verboseLogging: boolean; /** Enable strict validation (schema + action input checks) (default: true in prod/staging) */ strictValidation: boolean; + /** + * Escalate proposal-validation Phase 3 breaking-reference findings from WARN + * to BLOCK (default: true in prod/staging). Sibling of `strictValidation`: + * legitimate proposals are never blocked in dev/test, but production refuses + * proposals that break existing references. + */ + strictCompatibility: boolean; /** Enable detailed error messages in responses (default: true in dev/test) */ detailedErrors: boolean; /** Enable hot-reload / watch mode (default: true in dev) */ @@ -90,6 +97,7 @@ function buildConfig(name: EnvironmentName): EnvironmentConfig { features: { verboseLogging: isDevelopment || isTest, strictValidation: isProduction, + strictCompatibility: isProduction, detailedErrors: isDevelopment || isTest, hotReload: isDevelopment, requestLogging: isDevelopment, diff --git a/packages/core/src/engine/index.ts b/packages/core/src/engine/index.ts index 73ebc00bb..854fd18bd 100644 --- a/packages/core/src/engine/index.ts +++ b/packages/core/src/engine/index.ts @@ -177,3 +177,4 @@ export { validatePhase1, validateProposal, } from "./validation-engine"; +export { type ValidatePhase3Options, validatePhase3 } from "./validation-phase3"; diff --git a/packages/core/src/engine/validation-engine.ts b/packages/core/src/engine/validation-engine.ts index a90c9a305..51cd9ebf5 100644 --- a/packages/core/src/engine/validation-engine.ts +++ b/packages/core/src/engine/validation-engine.ts @@ -11,6 +11,7 @@ */ import type { EntityRegistry } from "../entity/entity-registry"; +import type { OntologyRegistry } from "../ontology/ontology-registry"; import type { ActionDefinition } from "../types/action"; import type { EntityDefinition, FieldType } from "../types/entity"; import type { @@ -24,6 +25,7 @@ import type { } from "../types/proposal"; import type { RuleDefinition } from "../types/rule"; import type { StateDefinition } from "../types/state"; +import { validatePhase3 } from "./validation-phase3"; // ── Valid field types ──────────────────────────────────── // Relationships are now declared via defineRelation(), not field types @@ -56,6 +58,18 @@ export interface ValidationContext { existingStates?: string[]; /** Existing event names */ existingEvents?: string[]; + /** + * Read-only semantic view of the CURRENT (pre-change) meta-model. Used by + * Phase 3 (compatibility) to detect breaking references. When absent, Phase 3 + * degrades to "skipped" (no findings) — existing callers stay unchanged. + */ + ontology?: OntologyRegistry; + /** + * Escalate Phase 3 breaking-reference findings from WARN to BLOCK. Default + * (false / undefined) → Phase 3 is warn-only and does NOT affect `passed`. + * Mirrors the `strictValidation` env-feature pattern. + */ + strictCompatibility?: boolean; } // ── Phase 1: Static checks ────────────────────────────── @@ -639,7 +653,7 @@ export function validateProposal(options: { // Phase 1: Static checks const phase1 = validatePhase1({ changes: proposal.changes, context }); - // Phase 2-4: Skipped for M1 + // Phase 2: Skipped for M1 (build check) const phase2: PhaseResult = { phase: 2, status: "skipped", @@ -647,13 +661,18 @@ export function validateProposal(options: { warnings: [], duration: 0, }; - const phase3: PhaseResult = { - phase: 3, - status: "skipped", - errors: [], - warnings: [], - duration: 0, - }; + + // Phase 3: Compatibility (breaking-reference) check (Spec 09 §4.5). + // Warn-only by default; gated to BLOCK via context.strictCompatibility. When + // the context carries no ontology, validatePhase3 returns "skipped" — same + // behavior as before this phase existed, so existing callers are unaffected. + const phase3 = validatePhase3({ + changes: proposal.changes, + ontology: context?.ontology, + strictCompatibility: context?.strictCompatibility, + }); + + // Phase 4: Skipped for M1 (test pass) const phase4: PhaseResult = { phase: 4, status: "skipped", diff --git a/packages/core/src/engine/validation-phase3.ts b/packages/core/src/engine/validation-phase3.ts new file mode 100644 index 000000000..6d4f75581 --- /dev/null +++ b/packages/core/src/engine/validation-phase3.ts @@ -0,0 +1,399 @@ +/** + * Validation Phase 3 — Compatibility (breaking-reference) checks (Spec 09 §4.5) + * + * Phase 3 inspects a proposal's removing/narrowing changes against the CURRENT + * meta-model (via the OntologyRegistry) and surfaces breaking references: when a + * change deletes or narrows an element that other definitions still depend on. + * + * Spec 09 §3.4 classifies `major` = delete field / change field type / change + * state machine / delete action. This phase detects the static, statically + * determinable subset of those: + * - delete of an entity field still referenced by a view / rule / relation + * - delete of an action / state / relation that has dependents + * - update that changes a field's type, drops a previously-required field's + * default, or removes an enum value (narrowing) + * + * Severity / gating (low-regret): + * - DEFAULT: WARN-ONLY. Findings are emitted as `warnings`; `passed` is NOT + * affected (status stays "passed"). Zero risk of false-positive blocking. + * - GATED: when `strictCompatibility` is true, findings become `errors` + * (status "failed" → proposal `passed` = false → blocks). + * + * OUT OF SCOPE for this increment (Spec 09 §4.5 also lists, but these require + * the build/migration pipeline, not static analysis): + * - DB migration safety (data loss / constraint breakage) + * - Blue-green backward compatibility of generated migrations + * These are intentionally left to a later Phase 2/3 build-time pass. + */ + +import type { OntologyRegistry } from "../ontology/ontology-registry"; +import type { EnumField, FieldDefinition } from "../types/entity"; +import type { MetaModelElementType } from "../types/meta-semantics"; +import type { + PhaseResult, + ProposalChange, + ValidationError, + ValidationWarning, +} from "../types/proposal"; +import type { RuleDefinition } from "../types/rule"; + +// ── Options ────────────────────────────────────────────── + +export interface ValidatePhase3Options { + /** The proposal's changes to inspect. */ + changes: ProposalChange[]; + /** + * Read-only semantic view of the CURRENT (pre-change) meta-model. When absent, + * Phase 3 cannot compute references and degrades to "skipped". + */ + ontology?: OntologyRegistry; + /** + * When true, breaking-reference findings become ERRORS (blocking). Default + * (false / undefined) → findings are WARNINGS only and do not affect `passed`. + */ + strictCompatibility?: boolean; +} + +// ── Internal finding shape ─────────────────────────────── + +interface BreakingFinding { + code: string; + message: string; + target?: string; + field?: string; +} + +// ── Entry point ────────────────────────────────────────── + +/** + * Run Phase 3 (compatibility) validation on a proposal's changes. + * + * Returns a PhaseResult: + * - no ontology in context → status "skipped" (no findings, never throws) + * - findings + strictCompatibility=false → status "passed" with `warnings` + * - findings + strictCompatibility=true → status "failed" with `errors` + */ +export function validatePhase3(options: ValidatePhase3Options): PhaseResult { + const { changes, ontology, strictCompatibility = false } = options; + const start = Date.now(); + + // Degrade gracefully: without the ontology we cannot determine references. + if (!ontology) { + return { + phase: 3, + status: "skipped", + errors: [], + warnings: [], + duration: Date.now() - start, + }; + } + + const findings: BreakingFinding[] = []; + + for (const change of changes) { + if (change.operation === "delete") { + detectDeletionBreakage(change, ontology, findings); + } else if (change.operation === "update") { + detectUpdateNarrowing(change, ontology, findings); + } + } + + const errors: ValidationError[] = []; + const warnings: ValidationWarning[] = []; + if (strictCompatibility) { + for (const f of findings) errors.push(f); + } else { + for (const f of findings) warnings.push(f); + } + + // Default warn-only: status is "passed" even with warnings — `passed` is only + // dragged false when strictCompatibility escalated findings to errors. + const status: PhaseResult["status"] = errors.length === 0 ? "passed" : "failed"; + + return { + phase: 3, + status, + errors, + warnings, + duration: Date.now() - start, + }; +} + +// ── Deletion breakage detection ────────────────────────── + +/** + * Flag a `delete` change when other definitions still reference the deleted + * element. Entity-field deletes are handled at field granularity; action / + * state / relation deletes reuse the OntologyRegistry impact graph. + */ +function detectDeletionBreakage( + change: ProposalChange, + ontology: OntologyRegistry, + findings: BreakingFinding[], +): void { + switch (change.target) { + case "entity": + detectEntityFieldDeletes(change, ontology, findings); + // A whole-entity delete (no surviving field definition) also breaks any + // action / view / rule / relation that references the ENTITY itself — not + // just its fields — so consult the impact graph for the entity node too. + if (extractFieldNames(change).size === 0) { + detectElementDeleteImpact(change.name, "entity", ontology, findings); + } + break; + case "action": + detectElementDeleteImpact(change.name, "action", ontology, findings); + break; + case "state": + detectElementDeleteImpact(change.name, "state", ontology, findings); + break; + default: + // view / event / rule / flow / overlay deletions are not reference + // *targets* in the dependency graph — nothing downstream breaks. + // (`relation` is not a ProposalChangeTarget today, so it can't appear here.) + break; + } +} + +/** + * An entity `delete` change may carry a partial replacement definition (the + * surviving fields). When the registry's current entity has fields the proposal + * no longer includes, those fields are being removed — flag any that are + * referenced by a view / rule / relation on that entity. + */ +function detectEntityFieldDeletes( + change: ProposalChange, + ontology: OntologyRegistry, + findings: BreakingFinding[], +): void { + const entityName = change.name; + const descriptor = ontology.describe(entityName); + if (!descriptor) return; // entity not in current model → nothing existing to break + + // Fields surviving in the proposal (delete change may omit a `definition`, + // meaning the whole entity is removed → every existing field is removed). + const survivingFields = extractFieldNames(change); + const removedFields = Object.keys(descriptor.fields).filter((f) => !survivingFields.has(f)); + + for (const fieldName of removedFields) { + for (const ref of findFieldReferences(entityName, fieldName, ontology)) { + findings.push({ + code: "BREAKING_FIELD_DELETE", + message: `Deleting field "${fieldName}" on entity "${entityName}" breaks ${ref}`, + target: entityName, + field: fieldName, + }); + } + } +} + +/** + * Use the OntologyRegistry impact graph: if anything depends on the element + * being deleted, the deletion is a breaking reference. + * + * KNOWN LIMITATION (conservative under-reporting): the impact DAG does not yet + * model edges for `StateDefinition.transitions[].action` or for an entity state + * field's `machine`. So deleting an action used ONLY as a state transition, or a + * state machine attached to an entity, may not surface a dependent here. This is + * tracked as a follow-up (enhance the ontology DAG); Phase 3 deliberately + * under-reports rather than false-positive-blocks. + */ +function detectElementDeleteImpact( + name: string, + type: MetaModelElementType, + ontology: OntologyRegistry, + findings: BreakingFinding[], +): void { + const layers = ontology.impactAnalysis({ type, name }); + // layers[0] = [root]; layers[1+] = dependents. No dependents → not breaking. + const dependents = layers.slice(1).flat(); + for (const dep of dependents) { + findings.push({ + code: "BREAKING_ELEMENT_DELETE", + message: `Deleting ${type} "${name}" breaks ${dep.type} "${dep.name}" which depends on it`, + target: name, + }); + } +} + +// ── Update narrowing detection ─────────────────────────── + +/** + * Flag an entity `update` change that narrows an existing field: type change, + * dropping a previously-required field's default, or removing an enum value. + */ +function detectUpdateNarrowing( + change: ProposalChange, + ontology: OntologyRegistry, + findings: BreakingFinding[], +): void { + if (change.target !== "entity") return; + const newFields = extractFields(change); + if (!newFields) return; + + const descriptor = ontology.describe(change.name); + if (!descriptor) return; // creating a new entity via update → no prior to narrow + + for (const [fieldName, newField] of Object.entries(newFields)) { + const oldField = descriptor.fields[fieldName]; + if (!oldField) continue; // newly-added field → not a narrowing + + // (1) Type change — major per Spec 09 §3.4. + if (oldField.type !== newField.type) { + findings.push({ + code: "BREAKING_FIELD_TYPE_CHANGE", + message: `Changing field "${fieldName}" on entity "${change.name}" from type "${oldField.type}" to "${newField.type}" is a breaking change`, + target: change.name, + field: fieldName, + }); + continue; // a type change subsumes default/enum narrowing reporting + } + + // (2) A still-required field that loses its default. If the proposal also + // makes the field optional (required: false), dropping the now-unneeded + // default is a loosening change, not breaking — so require newField.required. + if ( + oldField.required && + newField.required && + oldField.default !== undefined && + newField.default === undefined + ) { + findings.push({ + code: "BREAKING_REQUIRED_DEFAULT_DROP", + message: `Removing the default of required field "${fieldName}" on entity "${change.name}" is a breaking change`, + target: change.name, + field: fieldName, + }); + } + + // (3) Removing an enum value narrows the accepted set. + if (oldField.type === "enum" && newField.type === "enum") { + const removed = removedEnumValues(oldField, newField); + if (removed.length > 0) { + findings.push({ + code: "BREAKING_ENUM_VALUE_REMOVED", + message: `Removing enum value(s) [${removed.join(", ")}] from field "${fieldName}" on entity "${change.name}" is a breaking change`, + target: change.name, + field: fieldName, + }); + } + } + } +} + +// ── Field-reference scan ───────────────────────────────── + +/** + * Find which definitions on `entityName` reference `fieldName`. Returns + * human-readable reference descriptions (one per referencing definition). + */ +function findFieldReferences( + entityName: string, + fieldName: string, + ontology: OntologyRegistry, +): string[] { + const refs: string[] = []; + const descriptor = ontology.describe(entityName); + if (!descriptor) return refs; + + // Views: a ViewFieldConfig referencing the field. `fields` is typed required, + // but guard with optional chaining against malformed runtime view definitions. + for (const view of descriptor.views) { + if (view.fields?.some((vf) => vf.field === fieldName)) { + refs.push(`view "${view.name}"`); + } + if (view.defaultSort?.field === fieldName) { + refs.push(`view "${view.name}" (default sort)`); + } + } + + // Rules: fieldChange trigger or a condition reading the field. + for (const rule of descriptor.rules) { + if (ruleReferencesField(rule, entityName, fieldName)) { + refs.push(`rule "${rule.name}"`); + } + } + + // Relations: many_to_many junction properties referencing the field name. + for (const rel of descriptor.relations) { + // RelationDescriptor does not surface junction properties; relation + // navigation names are not field names, so only a property collision could + // break. The descriptor lacks that detail → skip (no false positives). + void rel; + } + + return refs; +} + +/** Does a rule reference `entityName.fieldName` via its trigger or condition? */ +function ruleReferencesField(rule: RuleDefinition, entityName: string, fieldName: string): boolean { + // Guard against malformed/incomplete rule definitions: `in` throws on a + // non-object trigger, so verify the shape before probing it. + const trigger = rule?.trigger; + if (trigger && typeof trigger === "object") { + if ("fieldChange" in trigger) { + if (trigger.fieldChange.entity === entityName && trigger.fieldChange.field === fieldName) { + return true; + } + } + // A stateChange trigger references the entity's state field implicitly, not + // a named field, so it is intentionally not matched here. + } + + // Declarative conditions reference fields by name. Code conditions are opaque. + const condition = rule?.condition; + if (condition && typeof condition !== "function") { + return declarativeConditionUsesField(condition, fieldName); + } + return false; +} + +/** Recursively check whether a declarative condition reads `fieldName`. */ +function declarativeConditionUsesField(condition: unknown, fieldName: string): boolean { + if (!condition || typeof condition !== "object") return false; + const c = condition as { + field?: unknown; + operator?: unknown; + conditions?: unknown; + condition?: unknown; + }; + if (typeof c.field === "string" && c.field === fieldName) return true; + if (Array.isArray(c.conditions)) { + return c.conditions.some((sub) => declarativeConditionUsesField(sub, fieldName)); + } + if (c.condition) { + return declarativeConditionUsesField(c.condition, fieldName); + } + return false; +} + +// ── Field-shape helpers ────────────────────────────────── + +/** Extract the `fields` record from an entity change definition, if present. */ +function extractFields(change: ProposalChange): Record | undefined { + const def = change.definition as { fields?: Record } | undefined; + if (!def || typeof def !== "object" || !def.fields) return undefined; + return def.fields; +} + +/** Names of fields surviving in a change's definition (empty if none). */ +function extractFieldNames(change: ProposalChange): Set { + const fields = extractFields(change); + return new Set(fields ? Object.keys(fields) : []); +} + +/** Enum values present in `oldField` but missing from `newField`. */ +function removedEnumValues(oldField: FieldDefinition, newField: FieldDefinition): string[] { + const oldOptions = enumOptionValues(oldField); + const newOptions = new Set(enumOptionValues(newField)); + return oldOptions.filter((v) => !newOptions.has(v)); +} + +/** Extract the set of enum option values from an enum field definition. */ +function enumOptionValues(field: FieldDefinition): string[] { + const enumField = field as Partial; + if (!Array.isArray(enumField.options)) return []; + return enumField.options + .map((o) => (o && typeof o === "object" ? o.value : undefined)) + .filter((v): v is string => typeof v === "string"); +} diff --git a/packages/core/src/exports/server/engines.ts b/packages/core/src/exports/server/engines.ts index 2bdb80432..f3aa64542 100644 --- a/packages/core/src/exports/server/engines.ts +++ b/packages/core/src/exports/server/engines.ts @@ -156,3 +156,7 @@ export { validatePhase1, validateProposal, } from "../../engine/validation-engine"; +export { + type ValidatePhase3Options, + validatePhase3, +} from "../../engine/validation-phase3";