Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions docs/lifecycle-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Lifecycle Events

Lifecycle events are Agentic-owned vocabulary for semantic facts about primitive operations. They answer "what happened?" without deciding who handles it, where it is stored, or how side effects run.

Agentic defines the event names and portable envelope in `src/types.ts`:

- `LifecycleEventName`
- `LifecycleEvent`
- `LifecycleEventSubject`
- `LifecycleEventRef`
- `LIFECYCLE_EVENTS`
- `LIFECYCLE_EVENT_PRIMITIVES`

## Boundary

Agentic owns lifecycle event vocabulary. Hosts and harnesses own execution semantics.

| Layer | Role | Owner |
| --- | --- | --- |
| Lifecycle event | Portable vocabulary for something that happened while using a primitive | Agentic |
| Local hook | CLI or harness convenience for running a local side effect | Harness/host |
| Dispatch | Portable message/routing shape a host may use to carry an event | Agentic shape, host execution |

A host may map lifecycle events into logs, metrics, queues, dispatches, webhooks, indexes, audit records, user-visible progress, or nothing. Agentic does not define retries, persistence, idempotency, authorization, or UI.

## Event Vocabulary

Initial semantic events:

- `artifact.created`
- `artifact.written`
- `artifact.locked`
- `persona.activated`
- `workflow.transitioned`
- `memory.remembered`
- `capability.requested`
- `capability.allowed`
- `capability.denied`
- `capability.completed`
- `approval.requested`
- `approval.granted`
- `approval.rejected`
- `approval.expired`

## Envelope

`LifecycleEvent` is intentionally small:

```ts
type LifecycleEvent = {
id?: string
name: LifecycleEventName
primitive: LifecyclePrimitive
subject: LifecycleEventSubject
timestamp: string
correlation_id?: string
related?: LifecycleEventRef[]
data?: Record<string, unknown>
}
```

`subject` identifies the primitive-owned thing the event is about, such as an artifact id, persona name, workflow run id, memory key, capability name, or approval request id.

`correlation_id` lets a host group multiple primitive events produced by one operation.

`related` lets composed operations remain explicit. For example, a workflow transition that also writes an artifact can emit or record a `workflow.transitioned` event related to an `artifact.written` event. Agentic does not require one event to contain the other's full payload.

`data` is primitive-specific context. Keep it portable and avoid provider internals; hosts can store private execution details elsewhere.

## Hooks Are Not Events

`.agentic/hooks/artifact.written` is one local way to react to the semantic event `artifact.written`. The event name is Agentic vocabulary; hook lookup, process execution, timeout policy, stderr handling, and failure behavior are harness behavior.

Existing CLI hooks continue to work as local dogfood. The lifecycle event vocabulary does not require hosts to use local hooks.

## Dispatch Is Not Event Handling

`Dispatch` is a portable inbound message shape and filter vocabulary. A host may choose to carry lifecycle events through dispatch, but Agentic does not provide queues, webhooks, schedulers, or handler execution.

This keeps the boundary clear: Agentic can say `approval.requested` happened; the host decides whether that becomes a Slack message, database row, audit log, retryable job, or no-op.
12 changes: 11 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ export type {
Dispatch,
DispatchFilter,
DispatchHandlerHooks,
LifecyclePrimitive,
LifecycleEventName,
LifecycleEventRef,
LifecycleEventSubject,
LifecycleEvent,
HookInvocation,
WorkflowRunStartedOutput,
WorkflowRunTerminatedOutput,
Expand All @@ -59,7 +64,12 @@ export type {
CapabilityArtifacts,
CapabilityDef,
} from "./types.js"
export { CAPABILITY_EFFECTS, POLICY_ERRORS } from "./types.js"
export {
CAPABILITY_EFFECTS,
POLICY_ERRORS,
LIFECYCLE_EVENTS,
LIFECYCLE_EVENT_PRIMITIVES,
} from "./types.js"

export type { MemoryAdapter, AdapterCapabilities } from "./memory/adapter.js"
export { FilesystemAdapter } from "./memory/filesystem.js"
Expand Down
65 changes: 65 additions & 0 deletions src/lifecycle-events.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, it } from "bun:test"
import type { LifecycleEvent, LifecycleEventName } from "./types.js"
import {
LIFECYCLE_EVENTS,
LIFECYCLE_EVENT_PRIMITIVES,
} from "./types.js"

describe("lifecycle event vocabulary", () => {
it("defines the semantic events from the framework boundary memo", () => {
const expected: readonly LifecycleEventName[] = [
"artifact.created",
"artifact.written",
"artifact.locked",
"persona.activated",
"workflow.transitioned",
"memory.remembered",
"capability.requested",
"capability.allowed",
"capability.denied",
"capability.completed",
"approval.requested",
"approval.granted",
"approval.rejected",
"approval.expired",
]

expect(LIFECYCLE_EVENTS).toEqual(expected)
})

it("maps every event name to its owning primitive", () => {
expect(Object.keys(LIFECYCLE_EVENT_PRIMITIVES).sort()).toEqual(
[...LIFECYCLE_EVENTS].sort(),
)
expect(LIFECYCLE_EVENT_PRIMITIVES["artifact.written"]).toBe("artifact")
expect(LIFECYCLE_EVENT_PRIMITIVES["approval.expired"]).toBe("approval")
})

it("supports related events for composed primitive operations", () => {
const event: LifecycleEvent<"workflow.transitioned"> = {
id: "01KNSAMPLEEVENT00000000000",
name: "workflow.transitioned",
primitive: "workflow",
subject: {
type: "workflow_run",
id: "01KNRUN000000000000000000",
},
timestamp: "2026-06-04T13:45:00.000Z",
correlation_id: "01KNCORRELATION000000000",
related: [
{
name: "artifact.written",
id: "01KNARTIFACTEVENT0000000",
},
],
data: {
graph_id: "briefing",
node_id: "write-brief",
to_status: "completed",
},
}

expect(event.related?.[0]?.name).toBe("artifact.written")
expect(event.data?.["node_id"]).toBe("write-brief")
})
})
97 changes: 97 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,103 @@ export type DispatchHandlerHooks = {
onUnregister?: () => Promise<void>
}

// ---------------------------------------------------------------------------
// Lifecycle event types
//
// Lifecycle events are semantic facts about primitive operations. Agentic owns
// the vocabulary and portable envelope; hosts own execution semantics: hooks,
// dispatch bridges, queues, persistence, retries, observability, and UI.
// ---------------------------------------------------------------------------

export type LifecyclePrimitive =
| "artifact"
| "persona"
| "workflow"
| "memory"
| "capability"
| "approval"

export type LifecycleEventName =
| "artifact.created"
| "artifact.written"
| "artifact.locked"
| "persona.activated"
| "workflow.transitioned"
| "memory.remembered"
| "capability.requested"
| "capability.allowed"
| "capability.denied"
| "capability.completed"
| "approval.requested"
| "approval.granted"
| "approval.rejected"
| "approval.expired"

export const LIFECYCLE_EVENTS: readonly LifecycleEventName[] = [
"artifact.created",
"artifact.written",
"artifact.locked",
"persona.activated",
"workflow.transitioned",
"memory.remembered",
"capability.requested",
"capability.allowed",
"capability.denied",
"capability.completed",
"approval.requested",
"approval.granted",
"approval.rejected",
"approval.expired",
] as const

export const LIFECYCLE_EVENT_PRIMITIVES: Readonly<Record<LifecycleEventName, LifecyclePrimitive>> = {
"artifact.created": "artifact",
"artifact.written": "artifact",
"artifact.locked": "artifact",
"persona.activated": "persona",
"workflow.transitioned": "workflow",
"memory.remembered": "memory",
"capability.requested": "capability",
"capability.allowed": "capability",
"capability.denied": "capability",
"capability.completed": "capability",
"approval.requested": "approval",
"approval.granted": "approval",
"approval.rejected": "approval",
"approval.expired": "approval",
} as const

export type LifecycleEventRef = {
name: LifecycleEventName
id?: string | undefined
}

export type LifecycleEventSubject = {
/** Primitive-owned subject kind, e.g. `artifact`, `persona`, `workflow_run`. */
type: string
/** Stable subject identifier when one exists, e.g. artifact id or run id. */
id?: string | undefined
/** Human-readable or catalog name when the subject is name-addressed. */
name?: string | undefined
/** Primitive version when relevant, e.g. artifact or graph version. */
version?: string | number | undefined
}

export type LifecycleEvent<TName extends LifecycleEventName = LifecycleEventName> = {
/** Optional host-assigned event id. Agentic does not require persistence. */
id?: string | undefined
name: TName
primitive: (typeof LIFECYCLE_EVENT_PRIMITIVES)[TName]
subject: LifecycleEventSubject
timestamp: string
/** Host/runtime correlation id for grouping related primitive events. */
correlation_id?: string | undefined
/** Related lifecycle events, e.g. a workflow transition that wrote an artifact. */
related?: LifecycleEventRef[] | undefined
/** Primitive-specific payload; hosts should keep provider internals out. */
data?: Record<string, unknown> | undefined
}

// ---------------------------------------------------------------------------
// Artifact types
//
Expand Down
Loading