Entity State Graphs & State Machine
Every entity in the graph passes through a lifecycle of states, driven by email metadata signals. A generic state machine engine enforces valid transitions, triggers side effects, and provides full auditability.
Person Lifecycle
A person is discovered the first time their email address appears in any metadata field. They become active when they send an email and transition to dormant after 30 days of silence. Re-engagement returns them to active from any state.
Person State Machine
| From | To | Trigger | |
|---|---|---|---|
| discovered | active | email_sent | |
| discovered | dormant | no_activity_30d | |
| active | dormant | no_activity_30d | |
| dormant | active | re_engaged | |
| dormant | archived | no_activity_90d | |
| dormant | archived | manual_archive | |
| archived | active | re_engaged |
Project Lifecycle
Projects are inferred from thread clusters with shared participants and subject lines. They move through a lifecycle from proposal to active work, completion or staleness, and eventual archival.
Project State Machine
| From | To | Trigger | |
|---|---|---|---|
| proposed | active | first_deliverable | |
| active | completed | goal_confirmed | |
| active | stale | no_activity_60d | |
| stale | active | re_engaged | |
| stale | archived | no_activity_90d | |
| completed | archived | auto_archive_30d |
Thread Lifecycle
Threads model email conversations. They are active while messages arrive, become stale after 14 days of silence, and can be marked resolved when a decision or completion signal is detected. Resolved threads auto-archive after 30 days.
Thread State Machine
| From | To | Trigger | |
|---|---|---|---|
| active | stale | no_activity_14d | |
| active | resolved | decision_detected | |
| stale | active | new_reply | |
| stale | resolved | decision_detected | |
| stale | archived | no_activity_60d | |
| resolved | archived | auto_archive_30d |
State Machine Engine
A generic, type-safe state machine engine enforces valid transitions for all entity types. Each entity type declares its own state graph; the engine validates every transition, runs optional pre-condition checks, and emits audit events.
// Generic state machine engine. Each entity type declares its
// own state graph; the engine enforces valid transitions and
// emits events on every state change.
export type StateTransitionRule<S extends string, T extends string> = {
from: S;
to: S;
trigger: T;
validate?: (entity: object) => boolean | Promise<boolean>;
};
export class StateMachineEngine<S extends string, T extends string> {
private rules: StateTransitionRule<S, T>[];
private onTransition?: (from: S, to: S, trigger: T) => void;
constructor(
rules: StateTransitionRule<S, T>[],
hooks?: { onTransition?: (from: S, to: S, trigger: T) => void },
) {
this.rules = rules;
this.onTransition = hooks?.onTransition;
}
async transition(
entity: { state: S; id: string },
trigger: T,
): Promise<{ state: S; applied: boolean }> {
const rule = this.rules.find(
(r) => r.from === entity.state && r.trigger === trigger,
);
if (!rule) {
throw new Error(
`Invalid transition: ${entity.state} cannot transition on "${trigger}"`,
);
}
if (rule.validate) {
const valid = await rule.validate(entity);
if (!valid) {
return { state: entity.state, applied: false };
}
}
this.onTransition?.(entity.state, rule.to, trigger);
return { state: rule.to, applied: true };
}
getAvailableTriggers(currentState: S): T[] {
return this.rules
.filter((r) => r.from === currentState)
.map((r) => r.trigger);
}
}
// Example: Person state machine configuration
export type PersonState = "discovered" | "active" | "dormant" | "archived";
export type PersonTrigger =
| "email_received"
| "email_sent"
| "no_activity_30d"
| "no_activity_90d"
| "manual_archive"
| "re_engaged";
export const PERSON_STATE_RULES: StateTransitionRule<PersonState, PersonTrigger>[] = [
{ from: "discovered", to: "active", trigger: "email_sent" },
{ from: "discovered", to: "dormant", trigger: "no_activity_30d" },
{ from: "active", to: "dormant", trigger: "no_activity_30d" },
{ from: "active", to: "active", trigger: "email_received", validate: () => true },
{ from: "active", to: "active", trigger: "email_sent", validate: () => true },
{ from: "dormant", to: "active", trigger: "re_engaged" },
{ from: "dormant", to: "archived", trigger: "no_activity_90d" },
{ from: "dormant", to: "archived", trigger: "manual_archive" },
{ from: "archived", to: "active", trigger: "re_engaged" },
];Applying State Transitions
State transitions are triggered by the same pipeline events that drive graph updates. When a new email is processed, the pipeline evaluates each affected entity's current state and applies the appropriate transition.
// State orchestrator: called by the GraphUpdater after each
// email is processed. Evaluates state transitions for every
// entity touched by the event.
export class StateOrchestrator {
private engines = new Map<string, StateMachineEngine<string, string>>();
constructor() {
this.engines.set("person", new StateMachineEngine(PERSON_STATE_RULES, {
onTransition: (from, to, trigger) => {
console.log(`Person ${from} -> ${to} via ${trigger}`);
},
}));
// Project and Thread engines registered similarly
}
async evaluateTransitions(
event: EmailMetadataEvent,
entities: { person: Person[]; thread: Thread | null; project?: Project },
): Promise<StateChange[]> {
const changes: StateChange[] = [];
// Evaluate each person entity
for (const person of entities.person) {
const triggers = this.determinePersonTriggers(person, event);
for (const trigger of triggers) {
const engine = this.engines.get("person")!;
try {
const result = await engine.transition(
{ state: person.state, id: person.email },
trigger,
);
if (result.applied) {
changes.push({
entityType: "person",
entityId: person.email,
fromState: person.state,
toState: result.state,
trigger,
timestamp: Date.now(),
});
person.state = result.state;
}
} catch {
// Invalid transition - log and skip
}
}
}
// Evaluate thread state
if (entities.thread) {
const threadTriggers = this.determineThreadTriggers(entities.thread, event);
// ... same pattern for thread
}
return changes;
}
private determinePersonTriggers(
person: Person,
event: EmailMetadataEvent,
): string[] {
const triggers: string[] = [];
if (event.from === person.email) {
triggers.push("email_sent");
}
if (event.to.includes(person.email) || event.cc.includes(person.email)) {
triggers.push("email_received");
}
// Time-based triggers are evaluated by a background cron,
// not in the hot path of email processing
return triggers;
}
private determineThreadTriggers(
thread: Thread,
event: EmailMetadataEvent,
): string[] {
const triggers: string[] = [];
triggers.push("new_reply");
if (event.extractedIntent?.type === "decision_record") {
triggers.push("decision_detected");
}
return triggers;
}
}
export type StateChange = {
entityType: string;
entityId: string;
fromState: string;
toState: string;
trigger: string;
timestamp: number;
};
// Time-based transitions run as a background cron, scanning
// entities where updated_at exceeds the threshold for the
// current state's next transition.
export async function evaluateTimeBasedTransitions(
store: EntityStore,
orchestrator: StateOrchestrator,
): Promise<StateChange[]> {
const now = Date.now();
const changes: StateChange[] = [];
// Persons: discovered -> dormant after 30d, active -> dormant after 30d, dormant -> archived after 90d
const thirtyDays = 30 * 86400_000;
const ninetyDays = 90 * 86400_000;
const stalePersons = await store.queryEntities("person", {
filter: {
anyOf: [
{ state: "discovered", updatedAt: { lt: now - thirtyDays } },
{ state: "active", updatedAt: { lt: now - thirtyDays } },
{ state: "dormant", updatedAt: { lt: now - ninetyDays } },
],
},
});
for (const person of stalePersons) {
const engine = orchestrator.getEngine("person")!;
const trigger = person.state === "dormant" ? "no_activity_90d" : "no_activity_30d";
const result = await engine.transition({ state: person.state, id: person.id }, trigger);
if (result.applied) {
changes.push({
entityType: "person",
entityId: person.id,
fromState: person.state,
toState: result.state,
trigger,
timestamp: now,
});
}
}
return changes;
}