feat(core): trait registry, 14 built-in traits, composition validator (U2)

This commit is contained in:
gsxdsm
2026-06-04 00:19:57 -07:00
parent 964744cd41
commit a5ac822de4
5 changed files with 1081 additions and 0 deletions

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import {
BUILTIN_TRAIT_DEFINITIONS,
BUILTIN_TRAIT_IDS,
registerBuiltinTraits,
} from "../builtin-traits.js";
import { TraitRegistry } from "../trait-registry.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
import type { WorkflowIrV2 } from "../workflow-ir-types.js";
function freshRegistry(): TraitRegistry {
const r = new TraitRegistry();
registerBuiltinTraits(r);
return r;
}
describe("built-in traits", () => {
it("ships exactly the 14 vocabulary traits", () => {
expect(BUILTIN_TRAIT_IDS).toHaveLength(14);
expect(BUILTIN_TRAIT_DEFINITIONS.map((d) => d.id).sort()).toEqual([...BUILTIN_TRAIT_IDS].sort());
});
it("all built-ins are flagged builtin: true and register cleanly", () => {
const r = freshRegistry();
for (const id of BUILTIN_TRAIT_IDS) {
const def = r.getTrait(id);
expect(def, `missing built-in trait ${id}`).toBeDefined();
expect(def?.builtin).toBe(true);
}
expect(r.listTraits()).toHaveLength(14);
});
it("only built-in traits carry restricted capabilities", () => {
const r = freshRegistry();
expect(r.getTrait("complete")?.flags.complete).toBe(true);
expect(r.getTrait("archived")?.flags.archived).toBe(true);
// Sync guards live only on built-ins (merge-blocker, human-review).
expect(r.getTrait("merge-blocker")?.hooks?.guard).toBe(true);
expect(r.getTrait("human-review")?.hooks?.guard).toBe(true);
// The plugin-facing gate trait uses the async gate hook, not a sync guard.
expect(r.getTrait("gate")?.hooks?.guard).toBeUndefined();
expect(r.getTrait("gate")?.hooks?.gate).toBe(true);
});
it("merge trait ships a config STUB shape (behavior is U7)", () => {
const r = freshRegistry();
const keys = (r.getTrait("merge")?.configSchema?.fields ?? []).map((f) => f.key).sort();
expect(keys).toEqual(["conflictStrategy", "fileScope", "squash", "strategy"]);
expect(r.getTrait("merge")?.flags.mergeOrchestration).toBe(true);
});
it("hold trait's release config matches WorkflowHoldRelease kinds", () => {
const r = freshRegistry();
const release = r.getTrait("hold")?.configSchema?.fields.find((f) => f.key === "release");
expect(release?.enumValues).toEqual([
"manual",
"timer",
"capacity",
"dependency",
"external-event",
]);
});
it("registering built-ins twice into the same registry is idempotent", () => {
const r = freshRegistry();
expect(() => registerBuiltinTraits(r)).not.toThrow();
expect(r.listTraits()).toHaveLength(14);
});
});
describe("default workflow columns validate cleanly", () => {
it("BUILTIN_CODING_WORKFLOW_IR columns pass the composition validator", () => {
const r = freshRegistry();
const ir = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2;
const violations = r.validateColumnTraits(ir.columns, "save");
expect(violations).toEqual([]);
});
it("the default workflow has exactly one intake column (triage)", () => {
const r = freshRegistry();
const ir = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2;
const intakeCols = ir.columns.filter((c) => r.resolveColumnFlags(c).intake);
expect(intakeCols.map((c) => c.id)).toEqual(["triage"]);
});
it("the default workflow's done column resolves the complete flag", () => {
const r = freshRegistry();
const ir = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2;
const done = ir.columns.find((c) => c.id === "done")!;
expect(r.resolveColumnFlags(done).complete).toBe(true);
});
it("the default workflow's in-progress column resolves wip+abort+timing flags", () => {
const r = freshRegistry();
const ir = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2;
const inProgress = ir.columns.find((c) => c.id === "in-progress")!;
const flags = r.resolveColumnFlags(inProgress);
expect(flags.countsTowardWip).toBe(true);
expect(flags.abortOnExit).toBe(true);
expect(flags.timing).toBe(true);
});
});

View File

@@ -0,0 +1,222 @@
import { describe, expect, it } from "vitest";
import {
TraitRegistry,
TraitRegistrationError,
} from "../trait-registry.js";
import type { TraitDefinition } from "../trait-types.js";
import type { WorkflowIrColumn } from "../workflow-ir-types.js";
function col(id: string, traits: string[]): WorkflowIrColumn {
return { id, name: id, traits: traits.map((t) => ({ trait: t })) };
}
function builtin(id: string, def: Partial<TraitDefinition>): TraitDefinition {
return { id, name: id, builtin: true, flags: {}, ...def };
}
function plugin(id: string, def: Partial<TraitDefinition>): TraitDefinition {
return { id, name: id, flags: {}, ...def };
}
/** A registry seeded with a representative built-in set used across tests. */
function seeded(): TraitRegistry {
const r = new TraitRegistry();
r.register(builtin("intake", { flags: { intake: true } }));
r.register(builtin("complete", { flags: { complete: true } }));
r.register(builtin("archived", { flags: { archived: true, hiddenFromBoard: true } }));
r.register(builtin("wip", { flags: { countsTowardWip: true } }));
r.register(builtin("wip2", { flags: { countsTowardWip: true } }));
r.register(builtin("merge-blocker", { flags: { mergeBlocker: true }, hooks: { guard: true } }));
r.register(builtin("timing", { flags: { timing: true }, hooks: { onEnter: true, onExit: true } }));
return r;
}
describe("TraitRegistry — registration", () => {
it("rejects a duplicate trait id", () => {
const r = new TraitRegistry();
r.register(builtin("intake", { flags: { intake: true } }));
expect(() => r.register(builtin("intake", { flags: { intake: true } }))).toThrowError(
TraitRegistrationError,
);
try {
r.register(builtin("intake", { flags: { intake: true } }));
} catch (err) {
expect((err as TraitRegistrationError).reason).toBe("duplicate-id");
}
});
it("blocks a non-builtin from overriding a built-in namespace id", () => {
const r = new TraitRegistry();
r.register(builtin("complete", { flags: { complete: true } }));
let caught: TraitRegistrationError | undefined;
try {
r.register(plugin("complete", { flags: {} }));
} catch (err) {
caught = err as TraitRegistrationError;
}
expect(caught).toBeInstanceOf(TraitRegistrationError);
expect(caught?.reason).toBe("builtin-namespace-protected");
});
it("rejects a non-builtin declaring the restricted `complete` flag", () => {
const r = new TraitRegistry();
let caught: TraitRegistrationError | undefined;
try {
r.register(plugin("my-plugin:done", { flags: { complete: true } }));
} catch (err) {
caught = err as TraitRegistrationError;
}
expect(caught?.reason).toBe("restricted-flag");
});
it("rejects a non-builtin declaring the restricted `archived` flag", () => {
const r = new TraitRegistry();
let caught: TraitRegistrationError | undefined;
try {
r.register(plugin("my-plugin:arch", { flags: { archived: true } }));
} catch (err) {
caught = err as TraitRegistrationError;
}
expect(caught?.reason).toBe("restricted-flag");
});
it("rejects a non-builtin declaring a sync `guard` hook (built-in only)", () => {
const r = new TraitRegistry();
let caught: TraitRegistrationError | undefined;
try {
r.register(plugin("my-plugin:guard", { flags: {}, hooks: { guard: true } }));
} catch (err) {
caught = err as TraitRegistrationError;
}
expect(caught?.reason).toBe("restricted-guard-hook");
});
it("allows a non-builtin declaring async-only hooks", () => {
const r = new TraitRegistry();
expect(() =>
r.register(
plugin("my-plugin:gate", {
flags: { gate: true },
hooks: { gate: true, onEnter: true, onExit: true, releaseCondition: true },
}),
),
).not.toThrow();
});
});
describe("TraitRegistry — flag resolution", () => {
it("merges effective flags across a column's traits (OR)", () => {
const r = seeded();
const flags = r.resolveColumnFlags(col("in-progress", ["wip", "timing"]));
expect(flags.countsTowardWip).toBe(true);
expect(flags.timing).toBe(true);
expect(flags.complete).toBeUndefined();
});
it("ignores unknown trait ids in flag resolution", () => {
const r = seeded();
const flags = r.resolveColumnFlags(col("x", ["wip", "nope"]));
expect(flags.countsTowardWip).toBe(true);
});
});
describe("TraitRegistry — composition validator", () => {
it("rejects complete + countsTowardWip with its reason code", () => {
const r = seeded();
const v = r.validateColumnTraits([col("c", ["complete", "wip"])]);
expect(v.find((x) => x.code === "complete-with-wip")?.severity).toBe("error");
});
it("rejects two capacity (wip) traits on one column", () => {
const r = seeded();
const v = r.validateColumnTraits([col("c", ["wip", "wip2"])]);
const hit = v.find((x) => x.code === "two-capacity-traits");
expect(hit?.severity).toBe("error");
expect(hit?.traitIds.sort()).toEqual(["wip", "wip2"]);
});
it("rejects complete + intake", () => {
const r = seeded();
const v = r.validateColumnTraits([col("c", ["complete", "intake"])]);
expect(v.find((x) => x.code === "complete-with-intake")?.severity).toBe("error");
});
it("rejects archived + countsTowardWip", () => {
const r = seeded();
const v = r.validateColumnTraits([col("c", ["archived", "wip"])]);
expect(v.find((x) => x.code === "archived-with-wip")?.severity).toBe("error");
});
it("rejects more than one intake column per workflow", () => {
const r = seeded();
const v = r.validateColumnTraits([col("a", ["intake"]), col("b", ["intake"])]);
expect(v.find((x) => x.code === "multiple-intake-columns")?.severity).toBe("error");
});
it("a valid single-intake / clean column set passes", () => {
const r = seeded();
const v = r.validateColumnTraits([
col("triage", ["intake"]),
col("in-progress", ["wip", "timing"]),
col("done", ["complete"]),
]);
expect(v).toEqual([]);
});
it("conflicting boolean flags reject at save (validator), not at runtime", () => {
const r = seeded();
// The conflict is surfaced by the validator (save-time), not on flag merge.
const flags = r.resolveColumnFlags(col("c", ["complete", "wip"]));
expect(flags.complete && flags.countsTowardWip).toBe(true); // merge does not throw
const v = r.validateColumnTraits([col("c", ["complete", "wip"])]);
expect(v.some((x) => x.code === "complete-with-wip" && x.severity === "error")).toBe(true);
});
it("unknown trait is a save-blocking error in save mode", () => {
const r = seeded();
const v = r.validateColumnTraits([col("c", ["totally-unknown"])], "save");
const hit = v.find((x) => x.code === "unknown-trait");
expect(hit?.severity).toBe("error");
});
it("load-time re-validation degrades unknown trait to advisory, not error", () => {
const r = seeded();
const v = r.validateColumnTraits([col("c", ["totally-unknown"])], "load");
const hit = v.find((x) => x.code === "unknown-trait");
expect(hit?.severity).toBe("degraded");
// The definition still "loads" — there is no error-severity violation.
expect(v.some((x) => x.severity === "error")).toBe(false);
});
});
describe("TraitRegistry — hook implementation DI", () => {
it("resolves a declared hook with no registered impl to a no-op + audit warning", () => {
const r = seeded();
const { impl, warning } = r.resolveTraitHook("merge-blocker", "guard");
expect(typeof impl).toBe("function");
expect(impl?.()).toBeUndefined(); // no-op
expect(warning?.kind).toBe("missing-hook-impl");
expect(warning?.traitId).toBe("merge-blocker");
expect(warning?.hookKind).toBe("guard");
});
it("resolves a registered impl without a warning", () => {
const r = seeded();
let called = false;
r.registerTraitHookImpl("merge-blocker", "guard", () => {
called = true;
return "ok";
});
const { impl, warning } = r.resolveTraitHook("merge-blocker", "guard");
expect(warning).toBeUndefined();
expect(impl?.()).toBe("ok");
expect(called).toBe(true);
});
it("returns no impl and no warning when the trait does not declare the hook", () => {
const r = seeded();
const { impl, warning } = r.resolveTraitHook("wip", "onEnter");
expect(impl).toBeUndefined();
expect(warning).toBeUndefined();
});
});

View File

@@ -0,0 +1,257 @@
/**
* The 14 built-in traits (U2, R7) from the Trait Vocabulary table. Behavior for
* each trait lands in later units; here we ship the definitions (flags + config
* schema + hook descriptors) and register them into the shared trait registry.
*
* The `merge` trait's config schema is a STUB here (shape only — strategy /
* fileScope / squash / conflictStrategy); its behavior is U7.
*
* Registration is idempotent at module scope (registered once on import). Tests
* that need a clean slate use `__resetTraitRegistryForTests()` +
* `registerBuiltinTraits(registry)`.
*/
import type { TraitDefinition } from "./trait-types.js";
import { TraitRegistry, getTraitRegistry } from "./trait-registry.js";
/** The ids of the 14 built-in traits, in vocabulary-table order. */
export const BUILTIN_TRAIT_IDS = [
"intake",
"complete",
"archived",
"merge-blocker",
"wip",
"hold",
"human-review",
"gate",
"merge",
"abort-on-exit",
"reset-on-entry",
"timing",
"stall-detection",
"notify",
] as const;
export type BuiltinTraitId = (typeof BUILTIN_TRAIT_IDS)[number];
/** The built-in trait definitions. */
export const BUILTIN_TRAIT_DEFINITIONS: TraitDefinition[] = [
{
id: "intake",
name: "Intake",
description: "Where new cards land; exactly one per workflow.",
builtin: true,
flags: { intake: true },
configSchema: {
fields: [{ key: "autoTriage", type: "boolean", description: "Auto-triage new cards" }],
},
},
{
id: "complete",
name: "Complete",
description: "Terminal success; satisfies dependencies. Restricted flag.",
builtin: true,
flags: { complete: true },
},
{
id: "archived",
name: "Archived",
description: "Hidden from board; global semantics. Restricted flag.",
builtin: true,
flags: { archived: true, hiddenFromBoard: true },
},
{
id: "merge-blocker",
name: "Merge blocker",
description:
"Generalized FN-5147: entry to complete-bound columns blocked until the merge-class node completed.",
builtin: true,
flags: { mergeBlocker: true },
hooks: { guard: true },
},
{
id: "wip",
name: "WIP / capacity",
description: "Substrate-enforced in-txn capacity limit; never bypassable (KTD-10).",
builtin: true,
flags: { countsTowardWip: true },
configSchema: {
fields: [
{ key: "limit", type: "number", required: true, description: "Max concurrent cards" },
{ key: "countPending", type: "boolean", description: "Count mid-transition cards" },
],
},
},
{
id: "hold",
name: "Hold",
description: "Passive dwell; released by a configured condition.",
builtin: true,
flags: { hold: true },
hooks: { releaseCondition: true },
configSchema: {
fields: [
{
key: "release",
type: "enum",
required: true,
enumValues: ["manual", "timer", "capacity", "dependency", "external-event"],
description: "Release condition kind (matches WorkflowHoldRelease)",
},
],
},
},
{
id: "human-review",
name: "Human review",
description:
"Card cannot leave until explicit human approval (approval state is a DB read — sync-safe). Not on the default workflow.",
builtin: true,
flags: { humanReview: true },
hooks: { guard: true },
configSchema: {
fields: [
{ key: "approvers", type: "array", description: "Allowed approver ids" },
{ key: "checklist", type: "array", description: "Required checklist items" },
],
},
},
{
id: "gate",
name: "Gate",
description:
"Workflow-step gate semantics generalized to columns; the plugin-facing gate surface. Blocking gates fail closed.",
builtin: true,
flags: { gate: true },
hooks: { gate: true },
configSchema: {
fields: [
{
key: "gateMode",
type: "enum",
required: true,
enumValues: ["blocking", "advisory"],
description: "Blocking gates fail closed; advisory gates record and allow",
},
{ key: "prompt", type: "string", description: "Gate prompt" },
{ key: "script", type: "string", description: "Gate script" },
],
},
},
{
id: "merge",
name: "Merge",
description:
"Enqueues onto the merge-request queue; configures merge policy. Behavior is U7 — this is a config STUB.",
builtin: true,
flags: { mergeOrchestration: true },
hooks: { onEnter: true, onExit: true },
configSchema: {
fields: [
{
key: "strategy",
type: "enum",
enumValues: ["squash", "merge-commit", "rebase", "pr-only"],
description: "Merge strategy",
},
{
key: "fileScope",
type: "enum",
enumValues: ["strict", "warn", "off", "custom"],
description: "File-scope enforcement mode",
},
{ key: "squash", type: "boolean", description: "Squash posture" },
{
key: "conflictStrategy",
type: "string",
description: "Conflict resolution strategy",
},
],
},
},
{
id: "abort-on-exit",
name: "Abort on exit",
description: "Generalized hard-cancel; bypassed by engine-sourced moves (KTD-9).",
builtin: true,
flags: { abortOnExit: true },
hooks: { onExit: true },
configSchema: {
fields: [
{
key: "direction",
type: "enum",
enumValues: ["backward", "any"],
description: "Which exits trigger abort",
},
{ key: "confirm", type: "boolean", description: "Require user confirmation" },
],
},
},
{
id: "reset-on-entry",
name: "Reset on entry",
description: "Legacy reopen-to-todo field/step resets.",
builtin: true,
flags: { resetOnEntry: true },
hooks: { onEnter: true },
configSchema: {
fields: [
{ key: "preserveProgress", type: "boolean", description: "Keep progress fields" },
],
},
},
{
id: "timing",
name: "Timing",
description: "cumulativeActiveMs accounting generalized.",
builtin: true,
flags: { timing: true },
hooks: { onEnter: true, onExit: true },
},
{
id: "stall-detection",
name: "Stall detection",
description: "In-review stall signals generalized to any column (sweep-evaluated).",
builtin: true,
flags: { stallDetection: true },
configSchema: {
fields: [
{ key: "timeoutMs", type: "number", required: true, description: "Stall threshold" },
{
key: "action",
type: "enum",
enumValues: ["annotate", "notify", "move"],
description: "Action on stall",
},
],
},
},
{
id: "notify",
name: "Notify",
description: "Basic notifications; richer notification traits are the canonical plugin example.",
builtin: true,
flags: { notify: true },
hooks: { onEnter: true, onExit: true },
configSchema: {
fields: [
{ key: "events", type: "array", description: "Events to notify on" },
{ key: "channel", type: "string", description: "Notification channel" },
],
},
},
];
/** Register all 14 built-in traits into the given registry (defaults to the
* shared registry). Idempotent guard for the shared instance lives in the
* module-scope registration below. */
export function registerBuiltinTraits(registry: TraitRegistry = getTraitRegistry()): void {
for (const def of BUILTIN_TRAIT_DEFINITIONS) {
if (registry.has(def.id)) continue;
registry.register(def);
}
}
// Register into the shared registry on import (idempotent via `has`).
registerBuiltinTraits();

View File

@@ -0,0 +1,375 @@
/**
* Trait registry (U2, R6/R8/R22).
*
* One registry resolving trait ids to definitions (flags + hook descriptors)
* for both built-ins and (later) plugins. Provides:
* - registration with `builtin:`-style namespace protection and
* restricted-capability enforcement (R22);
* - hook-implementation DI (engine registers impls; unregistered hooks
* resolve to a no-op + audit warning — degraded, not crashed);
* - effective-flag resolution for a column's trait set;
* - the save-time / load-time composition validator returning typed
* violations with named reason codes, distinguishing `error`
* (save-blocked) from `degraded` (load-time advisory).
*
* Core stays engine-free: no `@fusion/engine` import. Hook implementations are
* wired in via `registerTraitHookImpl` (mirrors `setCreateFnAgent`).
*/
import type {
TraitDefinition,
TraitFlags,
TraitHookImpl,
TraitHookKind,
} from "./trait-types.js";
import { RESTRICTED_TRAIT_FLAGS, traitHookKey } from "./trait-types.js";
import type { WorkflowIrColumn, WorkflowIrColumnTrait } from "./workflow-ir-types.js";
// ── Registration error ──────────────────────────────────────────────────────
/** Named reason codes for a rejected trait registration. */
export type TraitRegistrationReason =
| "duplicate-id"
| "builtin-namespace-protected"
| "restricted-flag"
| "restricted-guard-hook"
| "invalid-definition";
export class TraitRegistrationError extends Error {
readonly reason: TraitRegistrationReason;
readonly traitId: string;
constructor(reason: TraitRegistrationReason, traitId: string, message: string) {
super(message);
this.name = "TraitRegistrationError";
this.reason = reason;
this.traitId = traitId;
}
}
// ── Composition violation contract ──────────────────────────────────────────
/** Named reason codes for a composition violation. */
export type TraitViolationCode =
| "complete-with-wip"
| "two-capacity-traits"
| "complete-with-intake"
| "archived-with-wip"
| "multiple-intake-columns"
| "unknown-trait";
/** Severity: `error` blocks the save; `degraded` is a load-time advisory — the
* definition still loads (per U2's load-time re-validation requirement). */
export type TraitViolationSeverity = "error" | "degraded";
export interface TraitViolation {
code: TraitViolationCode;
severity: TraitViolationSeverity;
/** Column id the violation applies to, or null for workflow-wide violations. */
columnId: string | null;
/** The trait ids implicated (for actionable messaging). */
traitIds: string[];
message: string;
}
/** A simple audit-warning record returned by hook resolution / load-time
* re-validation. Modeled as a returned value (not a thrown error and not an
* engine logger) so core stays engine-free; callers may forward it to audit. */
export interface TraitAuditWarning {
kind: "missing-hook-impl" | "degraded-composition";
traitId?: string;
hookKind?: TraitHookKind;
message: string;
}
// ── The registry ────────────────────────────────────────────────────────────
export class TraitRegistry {
private readonly traits = new Map<string, TraitDefinition>();
private readonly hookImpls = new Map<string, TraitHookImpl>();
/** Register a trait. Rejects duplicates, builtin-namespace overrides by
* non-builtins, and restricted-capability declarations by non-builtins (R22). */
register(def: TraitDefinition): void {
if (!def.id || typeof def.id !== "string") {
throw new TraitRegistrationError(
"invalid-definition",
String(def.id),
"Trait definition must have a non-empty string id",
);
}
const existing = this.traits.get(def.id);
if (existing) {
// A built-in id (or any already-registered id) cannot be overridden.
if (!def.builtin && existing.builtin) {
throw new TraitRegistrationError(
"builtin-namespace-protected",
def.id,
`Trait id '${def.id}' is a built-in trait and cannot be overridden by a non-builtin registration`,
);
}
throw new TraitRegistrationError(
"duplicate-id",
def.id,
`Trait id '${def.id}' is already registered`,
);
}
if (!def.builtin) {
// Non-builtin (plugin) traits cannot declare restricted flags (R22).
for (const flag of RESTRICTED_TRAIT_FLAGS) {
if (def.flags?.[flag]) {
throw new TraitRegistrationError(
"restricted-flag",
def.id,
`Non-builtin trait '${def.id}' may not declare the restricted flag '${flag}'`,
);
}
}
// Non-builtin traits cannot declare the sync `guard` hook (KTD-2/R22).
if (def.hooks?.guard) {
throw new TraitRegistrationError(
"restricted-guard-hook",
def.id,
`Non-builtin trait '${def.id}' may not declare a sync 'guard' hook (built-in only)`,
);
}
}
this.traits.set(def.id, def);
}
getTrait(id: string): TraitDefinition | undefined {
return this.traits.get(id);
}
/** Catalog of all registered traits (for the dashboard endpoint, later). */
listTraits(): TraitDefinition[] {
return [...this.traits.values()];
}
has(id: string): boolean {
return this.traits.has(id);
}
// ── Hook implementation DI (engine wires impls in) ────────────────────────
/** Register a hook implementation for a (traitId, hookKind). Called by the
* engine (mirrors `setCreateFnAgent`); core never supplies impls. */
registerTraitHookImpl(traitId: string, hookKind: TraitHookKind, impl: TraitHookImpl): void {
this.hookImpls.set(traitHookKey(traitId, hookKind), impl);
}
/** Resolve a hook implementation. If the trait declares the hook but no impl
* is registered, returns a no-op plus an audit warning (degraded, not
* crashed). Returns `{ impl: undefined }` with no warning if the trait does
* not declare the hook at all. */
resolveTraitHook(
traitId: string,
hookKind: TraitHookKind,
): { impl: TraitHookImpl | undefined; warning?: TraitAuditWarning } {
const def = this.traits.get(traitId);
const declared = Boolean(def?.hooks?.[hookKind]);
const impl = this.hookImpls.get(traitHookKey(traitId, hookKind));
if (impl) return { impl };
if (declared) {
const noop: TraitHookImpl = () => undefined;
return {
impl: noop,
warning: {
kind: "missing-hook-impl",
traitId,
hookKind,
message: `Trait '${traitId}' declares a '${hookKind}' hook but no implementation is registered; resolving to a no-op`,
},
};
}
return { impl: undefined };
}
// ── Flag resolution ───────────────────────────────────────────────────────
/** Merged effective flags of a column's traits (OR across booleans). Unknown
* trait ids are ignored here (validation surfaces them via
* validateColumnTraits). */
resolveColumnFlags(column: WorkflowIrColumn): TraitFlags {
const merged: TraitFlags = {};
for (const ct of column.traits) {
const def = this.traits.get(ct.trait);
if (!def) continue;
for (const [key, value] of Object.entries(def.flags) as [keyof TraitFlags, boolean][]) {
if (value) merged[key] = true;
}
}
return merged;
}
// ── Composition validation ────────────────────────────────────────────────
/**
* Validate a workflow's columns' trait composition. Returns typed violations
* with named reason codes. `mode: "save"` produces `error` severities that
* block the save; `mode: "load"` degrades the *unknown-trait* violation to an
* advisory so definitions predating a newly added trait still load (per U2's
* load-time re-validation requirement). Hard structural conflicts remain
* errors in both modes (they reflect genuine nonsense, not vocabulary drift).
*/
validateColumnTraits(
columns: WorkflowIrColumn[],
mode: "save" | "load" = "save",
): TraitViolation[] {
const violations: TraitViolation[] = [];
let intakeColumnCount = 0;
for (const column of columns) {
const knownDefs: TraitDefinition[] = [];
// Unknown trait ids: degradable. In save mode it's an error; in load mode
// it degrades to an advisory (the rule/vocabulary may have changed under
// a persisted definition).
for (const ct of column.traits) {
const def = this.traits.get(ct.trait);
if (!def) {
violations.push({
code: "unknown-trait",
severity: mode === "load" ? "degraded" : "error",
columnId: column.id,
traitIds: [ct.trait],
message: `Column '${column.id}' references unknown trait '${ct.trait}'`,
});
continue;
}
knownDefs.push(def);
}
const flags = this.mergeFlags(knownDefs);
// Capacity traits on this column (traits whose flags set countsTowardWip).
const capacityTraitIds = knownDefs
.filter((d) => d.flags.countsTowardWip)
.map((d) => d.id);
if (flags.complete && flags.countsTowardWip) {
violations.push({
code: "complete-with-wip",
severity: "error",
columnId: column.id,
traitIds: this.traitIdsWithFlags(knownDefs, ["complete", "countsTowardWip"]),
message: `Column '${column.id}' is both a completion column and counts toward WIP — a terminal column cannot hold a capacity slot`,
});
}
if (capacityTraitIds.length > 1) {
violations.push({
code: "two-capacity-traits",
severity: "error",
columnId: column.id,
traitIds: capacityTraitIds,
message: `Column '${column.id}' has more than one capacity (WIP) trait: ${capacityTraitIds.join(", ")}`,
});
}
if (flags.complete && flags.intake) {
violations.push({
code: "complete-with-intake",
severity: "error",
columnId: column.id,
traitIds: this.traitIdsWithFlags(knownDefs, ["complete", "intake"]),
message: `Column '${column.id}' is both a completion column and an intake column`,
});
}
if (flags.archived && flags.countsTowardWip) {
violations.push({
code: "archived-with-wip",
severity: "error",
columnId: column.id,
traitIds: this.traitIdsWithFlags(knownDefs, ["archived", "countsTowardWip"]),
message: `Column '${column.id}' is archived but counts toward WIP — archived cards must not hold capacity`,
});
}
if (flags.intake) intakeColumnCount += 1;
}
if (intakeColumnCount > 1) {
violations.push({
code: "multiple-intake-columns",
severity: "error",
columnId: null,
traitIds: [],
message: `Workflow has ${intakeColumnCount} intake columns; exactly one is allowed`,
});
}
return violations;
}
private mergeFlags(defs: TraitDefinition[]): TraitFlags {
const merged: TraitFlags = {};
for (const def of defs) {
for (const [key, value] of Object.entries(def.flags) as [keyof TraitFlags, boolean][]) {
if (value) merged[key] = true;
}
}
return merged;
}
private traitIdsWithFlags(defs: TraitDefinition[], flagKeys: (keyof TraitFlags)[]): string[] {
return defs
.filter((d) => flagKeys.some((k) => d.flags[k]))
.map((d) => d.id);
}
}
// ── Module-level default registry ────────────────────────────────────────────
//
// A single shared registry instance the built-ins register into and the engine
// wires hook impls into. Tests can construct fresh `new TraitRegistry()`
// instances for isolation.
let defaultRegistry: TraitRegistry | undefined;
export function getTraitRegistry(): TraitRegistry {
if (!defaultRegistry) defaultRegistry = new TraitRegistry();
return defaultRegistry;
}
/** Test-only: reset the shared registry (so built-in registration can be
* re-exercised in isolation). */
export function __resetTraitRegistryForTests(): void {
defaultRegistry = undefined;
}
// ── Convenience pass-throughs to the default registry ────────────────────────
export function getTrait(id: string): TraitDefinition | undefined {
return getTraitRegistry().getTrait(id);
}
export function listTraits(): TraitDefinition[] {
return getTraitRegistry().listTraits();
}
export function resolveColumnFlags(column: WorkflowIrColumn): TraitFlags {
return getTraitRegistry().resolveColumnFlags(column);
}
export function validateColumnTraits(
columns: WorkflowIrColumn[],
mode: "save" | "load" = "save",
): TraitViolation[] {
return getTraitRegistry().validateColumnTraits(columns, mode);
}
export function registerTraitHookImpl(
traitId: string,
hookKind: TraitHookKind,
impl: TraitHookImpl,
): void {
getTraitRegistry().registerTraitHookImpl(traitId, hookKind, impl);
}
/** Re-export for callers that only need the column-trait shape. */
export type { WorkflowIrColumnTrait };

View File

@@ -0,0 +1,125 @@
/**
* Trait model (U2). A trait is declarative flags + optional config schema +
* optional executable lifecycle hook *descriptors*. Per KTD-2 there are two
* guard classes:
* - `guard` — sync, in-lock, fast/pure (DB reads only). BUILT-IN ONLY.
* - `gate` — async, pre-evaluated outside the lock; the plugin-facing
* surface. The verdict is recorded and re-checked cheaply in-lock.
*
* Hooks here are *descriptors* (what the trait declares it participates in);
* the executable implementations are registered separately by the engine via
* the core→engine DI seam (mirrors `setCreateFnAgent`). This keeps core
* engine-free: core never imports `@fusion/engine`.
*/
/** The set of hook points a trait can declare (KTD-2). */
export type TraitHookKind = "guard" | "gate" | "onEnter" | "onExit" | "releaseCondition";
/** All declarative trait flags. Derived from the Trait Vocabulary table.
* Every flag is optional; an absent flag means `false`. Flags compose by OR
* across a column's traits (see resolveColumnFlags). */
export interface TraitFlags {
/** Cards in this column count against a WIP/capacity limit (substrate-enforced). */
countsTowardWip?: boolean;
/** Terminal-success column; satisfies dependencies. RESTRICTED (built-in only). */
complete?: boolean;
/** Globally archived; hidden from the board. RESTRICTED (built-in only). */
archived?: boolean;
/** Hidden from the board lane (e.g. archived columns). */
hiddenFromBoard?: boolean;
/** Leaving this column hard-cancels in-flight work (abort-on-exit). */
abortOnExit?: boolean;
/** Cards cannot leave until explicit human approval. */
humanReview?: boolean;
/** Where new cards land; exactly one per workflow (validated). */
intake?: boolean;
/** Passive dwell column with a release condition. */
hold?: boolean;
/** Participates in merge/PR orchestration (enqueues onto the merge queue). */
mergeOrchestration?: boolean;
/** Entry to this column is blocked until the merge-class node completed. */
mergeBlocker?: boolean;
/** Card progress/fields are reset on entry (reopen semantics). */
resetOnEntry?: boolean;
/** Cumulative active-time accounting runs on enter/exit. */
timing?: boolean;
/** Stall detection is evaluated by the sweep for cards dwelling here. */
stallDetection?: boolean;
/** Emits notifications on enter/exit. */
notify?: boolean;
/** A gate (advisory or blocking) is evaluated before entry. */
gate?: boolean;
}
/** The flag keys that are restricted to built-in traits (R22, KTD-7). A
* non-builtin (plugin) trait declaring any of these is rejected at
* registration. The sync `guard` hook descriptor is restricted separately. */
export const RESTRICTED_TRAIT_FLAGS = ["complete", "archived"] as const;
export type RestrictedTraitFlag = (typeof RESTRICTED_TRAIT_FLAGS)[number];
/** A trait's hook descriptors — *what* the trait declares it participates in.
* `true` means "this trait has a hook of this kind"; the implementation is
* registered separately via the engine DI seam. */
export interface TraitHookDescriptors {
/** Sync, in-lock guard. BUILT-IN ONLY (KTD-2/R22). */
guard?: boolean;
/** Async, pre-evaluated gate. Plugin-facing surface. */
gate?: boolean;
/** Post-commit, async, idempotent enter effect. */
onEnter?: boolean;
/** Post-commit, async, idempotent exit effect. */
onExit?: boolean;
/** Release-condition evaluation for hold columns (sweep-driven). */
releaseCondition?: boolean;
}
/** A declarative description of a trait's config schema. Lightweight by design
* (U2 ships the shapes; richer validation lands with each behavior unit). */
export interface TraitConfigField {
key: string;
type: "string" | "number" | "boolean" | "enum" | "object" | "array";
required?: boolean;
/** For `enum` fields: the allowed values. */
enumValues?: readonly string[];
description?: string;
}
export interface TraitConfigSchema {
fields: TraitConfigField[];
}
/** A trait definition: declarative flags + optional config schema + optional
* hook descriptors. Built-in traits set `builtin: true`. */
export interface TraitDefinition {
id: string;
name: string;
description?: string;
flags: TraitFlags;
configSchema?: TraitConfigSchema;
hooks?: TraitHookDescriptors;
/** True for the 14 built-in traits; plugin/custom traits leave this falsy.
* Restricted capabilities (R22) are allowed only when `builtin` is true. */
builtin?: boolean;
}
// ── Hook implementation DI seam (core→engine) ───────────────────────────────
//
// Implementations of trait hooks are NOT defined in core (core is engine-free).
// The engine registers them via `registerTraitHookImpl` the way it wires
// `setCreateFnAgent`. Core resolves an implementation through
// `getTraitHookImpl`; an unregistered hook resolves to a no-op (the registry's
// `resolveTraitHook` returns a no-op + an audit warning, see trait-registry).
//
// The impl signature is intentionally opaque here: core never invokes hooks
// directly (the store/sweep do, in engine-adjacent code), so core only needs to
// store/retrieve the registration. Using `unknown` keeps core free of engine
// types while remaining type-safe at the registration boundary.
/** A registered hook implementation. Opaque to core; the engine supplies a
* concrete callable and casts at its own call sites. */
export type TraitHookImpl = (...args: unknown[]) => unknown;
/** Stable key for a (traitId, hookKind) implementation registration. */
export function traitHookKey(traitId: string, hookKind: TraitHookKind): string {
return `${traitId}::${hookKind}`;
}