# v0.3 — Graph Evolution: Event Nodes (Cognitive Phase 3 PR-11)

> Status: design-only (no code yet)
> Pinned to: `docs/ARCHITECTURE.md` §5.7.6 (memory scope layering)
> Tracks: `docs/handovers/v0.3-cognitive-layer-track.md` Phase 3
> Companion documents:
>   - `docs/design/v0.3-knowledge-cascade.md` (the `sources[]` schema we reuse)
>   - `docs/design/v0.3-episodic-memory.md` (the session-scoped trail we do **not** auto-promote)
>   - `core/relations_schema.py` (the noisy-OR confidence helper we extend)
>   - `core/wiki_generator.py` (the current 4 entity types we add a 5th to)

## 0. TL;DR

JAMES's graph today has four entity types — `person`, `concept`,
`org`, `document`. All four are **atemporal**: a relation says
*"NVIDIA RELATED_TO Jensen Huang"*, with no notion of when. Time-bound
knowledge gets squeezed into `concept` nodes (e.g. *"2026 비트코인
ETF 승인"*), which loses the time axis for retrieval, cascade, and
the planner's *"what was the state of X on date Y"* questions.

PR-11 introduces a fifth entity type — **`event`** — with one
required field beyond the existing schema: `occurred_at`. Events
participate in the same relation graph, the same `sources[]`
provenance, and the same cascade. They are workspace-scoped, not
session-scoped: episodic memory does **not** auto-promote to event
nodes; the user gates promotion via the existing "save as long-term
memory" modal (PR #264).

**Why optional (the handover marks PR-11 "선택")**: the four current
entity types cover most v0.3 use cases. Event nodes pay off when a
workspace accumulates >100 timed assertions and time-aware queries
start mattering (post-event impact analysis, "what changed between
dates A and B", forensic timelines). PR-11 ships the schema and the
ingest path so that capability is available the moment a workspace
needs it, without a later migration.

## 1. Goals

| # | Goal | Measure |
|---|---|---|
| G1 | Time-bound knowledge gets a first-class home in the graph | New `event` entity type, validated by `wiki_generator.py:132`-style enumeration |
| G2 | Event nodes use the same provenance + cascade as relations | Same `sources: [{doc_id, weight, role, ts}]` schema from Knowledge Cascade Phase A; `compute_confidence_from_sources()` reused unchanged |
| G3 | Existing 4-type graph is byte-identical when no event ingested | STEP 7 bench passes byte-identical relative to a fresh capture from a graph with zero `event` files |
| G4 | Plugin API can specialize events without forking the core | `OntologyPack.entity_types` already supports adding subtypes; PR-11 defines the inheritance contract |
| G5 | Episodic ↔ graph bridge stays explicit (user-gated) | No code path takes an `EpisodicEvent` and auto-creates a graph event node; bridge goes through the existing PR #264 modal + MemoryLoom |

## 2. Non-goals

- A full temporal logic over the graph (Allen interval relations,
  before/after/during operators). The first cut stores `occurred_at`
  as an ISO 8601 string; query-time temporal reasoning lives in the
  cognitive layer, not the graph schema.
- Retroactive promotion of existing `concept` nodes that smell like
  events (e.g. *"2026 비트코인 ETF 승인"*). Operators may run a
  one-off migration script if desired; PR-11 does not autonomously
  reclassify.
- Event subtypes baked into the core (`meeting`, `release`, `incident`,
  …). Subtypes belong to ontology packs (`OntologyPack.entity_types`)
  and the core stays type-agnostic above the `event` umbrella.
- Cross-workspace event sharing or federation. v0.3 stays
  single-workspace per §5.7.7.

---

## 3. Position in the scope hierarchy (§5.7.6)

Event nodes sit firmly in **workspace scope**, alongside the existing
entity types and the audit log:

```
┌────────────────────────────────────────────────────────────────┐
│  system     — ontology, PolicyEngine rules, evolutions        │
├────────────────────────────────────────────────────────────────┤
│  workspace  — entities {person, concept, org, document,       │
│               EVENT (new) }, vector store, audit_log          │
├────────────────────────────────────────────────────────────────┤
│  session    — episodic events, working memory, drafts          │
│                                                                │
│  ▲ promotion to a workspace `event` is **user-gated** via the  │
│    existing PR #264 "save as long-term memory" modal —         │
│    episodic never auto-creates a graph node                    │
└────────────────────────────────────────────────────────────────┘
```

The `event` entity type is **not** a renaming of `EpisodicEvent`.
The two concepts share a word but live in different scopes and serve
different access patterns:

| | `EpisodicEvent` (PR-9) | `event` graph node (PR-11) |
|---|---|---|
| Scope | session | workspace |
| Lifetime | 7-day prune | until cascade removes the last source |
| Identity | ULID, session-bound | `e_event_<hash>`, global |
| Retrieval | `WHERE session_id = ?` | hybrid search + graph traversal |
| Promotion | user-gated only (PR #264 modal) | n/a |

Conflating them would violate the §5.7.6 *"writes never escape
upward"* invariant — which is precisely the reason episodic was kept
ephemeral in PR-9.

---

## 4. Data Model

### 4.1 Entity file shape

A new file under `wiki/entity/prod/event/<normalized_name>.md`:

```yaml
---
entity_id: e_event_a1b2c3d4
entity_type: event
name: 2026 비트코인 ETF 승인
aliases: [Bitcoin Spot ETF approval, BTC ETF 승인]
occurred_at: "2026-01-10"          # REQUIRED for event; ISO 8601
occurred_at_precision: day         # one of: year | month | day | hour | minute
sources:                           # same schema as Knowledge Cascade
  - doc_id:  d_sec_filing_2026_01
    weight:  0.85
    role:    extract
    ts:      "2026-01-10T15:32:00Z"
relations:
  - target:      SEC
    target_id:   e_org_84ad0091
    target_type: org
    type:        APPROVED_BY
    sources:
      - { doc_id: d_sec_filing_2026_01, weight: 0.90, role: extract, ts: "..." }
  - target:      비트코인
    target_id:   e_concept_b34d
    target_type: concept
    type:        SUBJECT_OF
    sources:
      - { doc_id: d_sec_filing_2026_01, weight: 0.95, role: extract, ts: "..." }
---

# Event body — narrative summary, same markdown convention as other
# entities. Reasoning over the event uses the body; graph traversal
# uses the frontmatter.
```

Two new frontmatter fields beyond what `person`/`concept`/`org`/
`document` carry:

- `occurred_at` (required) — ISO 8601 string. **Required** at write
  time; ingestion that cannot determine a date refuses to create the
  event (a `concept` node is the right fallback for undated
  knowledge).
- `occurred_at_precision` (optional, default `day`) — quantizes
  retrieval-time time-bucket queries without forcing the user to
  fabricate hours/minutes on a quarterly-report event.

The `sources[]` array on both the entity-level and the relation-level
is exactly the Knowledge Cascade Phase A schema. No changes to
`core/relations_schema.py`.

### 4.2 Reserved relation types

Three relation types are recommended (not enforced) for events to
keep the ontology readable:

| Relation | Subject | Object | Meaning |
|---|---|---|---|
| `INVOLVED` | event | person/org | A participant in the event |
| `SUBJECT_OF` | event | concept | What the event is about |
| `RESULTED_IN` | event | event/concept | Causal forward link |

These are documented in `docs/design/v0.3-graph-evolution.md` (this
file) and the schema lint warns on unknown event-side relation types,
but the warning is non-fatal — operators add their own as needed
(post-1.0 ontology packs may close this).

### 4.3 Entity-type enumeration

The current literal `["person", "concept", "org", "document"]` in
`core/wiki_generator.py:132` (and the three mirrors in
`tests/test_graph_snapshot.py:55`,
`tests/test_wiki_resolve_unresolved.py:60`, etc.) lifts to a single
module-level constant in `core/relations_schema.py`:

```python
ENTITY_TYPES_CORE: tuple[str, ...] = (
    "person", "concept", "org", "document", "event",
)
```

Ontology packs (`OntologyPack.entity_types`) extend this at process
startup — the loader in `core/plugins/` (PR-C3, pending) merges the
core tuple with each pack's `entity_types`. The merge is a tuple
union, not a replacement, so packs can add but never remove core
types.

---

## 5. API surface

### 5.1 Ingest path (extract from documents)

The LLM extraction prompt used by `core/wiki_generator.py` already
asks for `entities: [{name, type, ...}]`. PR-11 adds one extraction
hint:

```
event entities MUST include an occurred_at field (ISO 8601). If you
cannot determine the date from the document, emit the entity as
"concept" instead — do not invent a date.
```

The extraction post-processor in `wiki_generator.py` rejects any
`type: event` entity that lacks `occurred_at` (raises a parse error
that surfaces to the admin during the ingest preview).

### 5.2 Graph editor (manual events)

`core/graph_node_editor.py` already exposes a `PUT /admin/graph/node`
that updates frontmatter fields on an existing entity. PR-11 extends:

- `POST /admin/graph/event` — create a new event node. Body:
  `{name, occurred_at, occurred_at_precision?, aliases?,
  source_doc_id?, source_weight?}`. The created event gets one
  `source` with `role: "manual"`.
- The existing `PUT /admin/graph/node` accepts `occurred_at` /
  `occurred_at_precision` updates when the target node is
  `entity_type=event`. For other entity types the two fields are
  ignored (no schema violation if accidentally sent).

Both endpoints gate on `workspace.graph_edit` (the existing permission
used by PR-O6).

### 5.3 Retrieval extension

The retrieval-loop change is minimal — events flow through the same
hybrid search as other entities. The one new affordance is a
**time-bucket filter** on `/api/graph/search`:

- `occurred_after` / `occurred_before` (ISO 8601 strings, both optional)
- Filter applied **after** retrieval scoring, so the ranker still
  ranks by relevance; time is a hard cut, not a re-weight.

Concept nodes (no `occurred_at`) pass the filter only when both
`occurred_after` and `occurred_before` are absent — a time-scoped
query implicitly restricts to event nodes, matching the user's
expressed intent.

### 5.4 Plugin API hook

`OntologyPack.entity_types` already supports extension. A pack that
wants `meeting` and `release` as event subtypes registers them as
top-level entity types whose ingest contract requires `occurred_at`:

```python
class CalendarPack:
    pack_id = "calendar"
    entity_types = ("meeting", "release")  # both require occurred_at
    ...
```

PR-11 ships a marker — `EVENT_LIKE_ENTITY_TYPES` — in
`core/relations_schema.py` listing the core `event` plus any pack
type that opts in:

```python
EVENT_LIKE_ENTITY_TYPES: set[str] = {"event"}  # mutated by loader at startup
```

The ingest post-processor checks membership rather than `== "event"`.

---

## 6. PolicyEngine integration

Three surfaces require gate review:

1. **Event creation via graph editor** — `workspace.graph_edit`
   (existing permission, no new key).
2. **Time-bucket retrieval** — no new permission; the filter is a
   refinement of the existing `workspace.graph_search`.
3. **Promotion from episodic to event** — gated by the existing
   PR #264 "save as long-term memory" modal flow. PR-11 does **not**
   add a permission for episodic promotion because no code path
   creates an event from episodic state without a user click.

The MemoryLoom hook (the destination of PR #264's modal save) gains
one branch: if the saved insight has a parseable date, MemoryLoom
proposes an `event` node instead of a `concept` node. The user
confirms or overrides in the same modal step.

---

## 7. Cascade integration (Knowledge Cascade)

Event nodes participate in `core/cascade.py` exactly like other
entities. The file-delete cascade walks `sources[]` on both
entity-level frontmatter and per-relation entries, dropping rows
whose `doc_id` matches the deleted document.

Two cascade refinements specific to event nodes:

- **Orphan event sweep**: if an event node's `sources[]` array empties
  after a cascade (no `extract` rows, no `manual` rows), the entity
  file is removed in the same sweep — same logic that
  `core/cascade.py` already applies to relations whose noisy-OR
  confidence drops to 0.
- **`role: "legacy"` immunity**: a legacy source on an event node is
  treated identically to a legacy source on a relation — cascade
  skips it (Knowledge Cascade Phase A non-goal: pre-migration
  back-fills don't carry doc lineage).

No change to `core/cascade.py`'s public API is required. The cascade
already iterates frontmatter and the existing `sources[]` walker
covers entity-level rows the moment we add the field.

---

## 8. Episodic ↔ graph bridge

The bridge is **explicit, user-gated, and one-way (session →
workspace)**. It runs as follows:

1. During a turn, the cognitive middleware writes `EpisodicEvent`
   rows (PR-9b wiring) describing what was retrieved, planned,
   reflected, verified.
2. The user clicks "save as long-term memory" on the chat modal
   (PR #264).
3. MemoryLoom (`core/memory/loom.py`) summarizes the marked turn.
4. If MemoryLoom detects a date-bearing assertion in the summary
   (regex + light LLM parse), it proposes an `event` node creation
   in the same modal. The user accepts → `POST /admin/graph/event`
   fires with `role: "manual"`.
5. If MemoryLoom detects no date, the existing `concept` promotion
   path is used.

No code path auto-promotes an `EpisodicEvent` to a graph `event`
without the modal click. This preserves §5.7.6's *"writes never
escape upward"* invariant.

---

## 9. What PR-11 does NOT do

- **Reclassify existing `concept` nodes**. Operators run a one-off
  script if desired (`scripts/reclassify_concepts_to_events.py` —
  not part of PR-11). Auto-reclassification would surprise users
  who indexed by `concept`.
- **Temporal reasoning primitives** (Allen intervals, before/after
  operators, period arithmetic). The cognitive layer can compose
  these atop `occurred_at` strings; the schema stays minimal.
- **Cross-event causal inference** beyond the `RESULTED_IN`
  relation. Causal chains are reasoning-time, not schema-time.
- **A timeline UI**. Frontend visualization of events on a timeline
  is a separate cycle (consider it once the workspace has >50
  events).
- **Event-specific access control** (e.g. "only admin can see
  events past 2025"). Events use the same ABAC role as other
  entities; PolicyEngine can already filter on metadata if the
  workspace later demands it.

---

## 10. Test plan (for PR-11a — code lands separately)

1. **Entity-type enumeration** — `ENTITY_TYPES_CORE` returns 5
   elements including `event`; the four existing literals in code
   resolve to the same tuple.
2. **Schema validation** — creating an event without `occurred_at`
   raises `ValueError`; creating with malformed ISO 8601 raises.
3. **occurred_at precision** — `year` / `month` / `day` / `hour` /
   `minute` accepted; unknown precision raises.
4. **Sources reuse** — event node with two extract sources computes
   noisy-OR confidence identical to a relation with the same sources
   (calls `compute_confidence_from_sources` unchanged).
5. **Cascade — drop one extract** — event with two sources, deleting
   one source-doc leaves the event with one source; confidence
   recomputes; entity file remains.
6. **Cascade — orphan sweep** — event with one extract source, the
   doc is deleted, the event file is removed in the same cascade
   sweep.
7. **Cascade — manual immunity** — event with one extract + one
   manual source, the extract doc is deleted, event survives with
   the manual source only.
8. **Time-bucket filter** — `occurred_after` / `occurred_before`
   filter on `/api/graph/search` returns only matching events;
   concept nodes are filtered out when a time bound is set.
9. **Plugin extension** — a fake `OntologyPack` declaring
   `entity_types=("meeting",)` extends `EVENT_LIKE_ENTITY_TYPES`
   after loader runs; ingest validation requires `occurred_at` on
   `meeting` entities.
10. **Episodic NO auto-promotion** — running the cognitive pipeline
    for a turn that contains a date-bearing assertion does **not**
    create a graph event node; only the PR #264 modal path does.
11. **STEP 7 baseline** — bench passes byte-identical relative to a
    workspace with zero `event` entities (no regression on the four
    existing types).

---

## 11. PR sequence

| PR | Scope |
|---|---|
| **PR-11a** | `core/relations_schema.py` adds `ENTITY_TYPES_CORE` + `EVENT_LIKE_ENTITY_TYPES` + `validate_occurred_at()` helper. `core/wiki_generator.py` lifts the literal. `core/cascade.py` is unchanged (already iterates frontmatter). Tests 1–7 + 11. **No ingest wiring** — events can be created via `POST /admin/graph/event` but ingestion still produces only 4 types. |
| **PR-11b** | Wire ingestion to emit `event` entities when extraction returns `type: event` with `occurred_at`. Tests 2, 4. Updates the extraction prompt with the "MUST include occurred_at" hint. |
| **PR-11c** | Time-bucket filter on `/api/graph/search`. Test 8. |
| **PR-11d** | MemoryLoom date detection — propose `event` instead of `concept` in the PR #264 modal save path. Test 10. |
| **PR-11e (selective)** | `OntologyPack` loader (after PR-C3 lands) populates `EVENT_LIKE_ENTITY_TYPES` from registered packs. Test 9. Depends on PR-C3. |

PR-11a / 11b / 11c can each ship under 200 LOC and stay
independently revertible. PR-11d depends on PR-9b being live in
workspace mode (it is, as of #354). PR-11e depends on a code event
that has not happened yet (no external pack); ships when it does.

---

## 12. Open questions (resolve in PR-11a or before)

1. **`occurred_at` parsing strictness** — accept partial dates
   (`"2026"`, `"2026-Q1"`) or insist on full ISO 8601? Proposal:
   `occurred_at_precision` quantizes the date, but storage stays
   full ISO 8601. The precision marker tells consumers whether to
   trust the day/hour fields.
2. **Identity collision** — two events on the same date with the
   same name (e.g. two quarterly earnings reports on the same day
   from two companies). The `entity_id` includes the type + a hash
   of `name + occurred_at + occurred_at_precision`, which gives
   each a distinct id without forcing the user to disambiguate.
3. **Future-dated events** — should the schema reject an
   `occurred_at` in the future? Proposal: no. A workspace tracking
   upcoming releases is a valid use case. Operators may add a
   `validates_future` flag per pack if they want stricter behavior.
4. **Pruning** — events never auto-prune. Old events are workspace
   knowledge; deletion goes through the cascade or the graph
   editor. (Episodic still prunes at 7 days; the two are
   independent.)

---

## 13. Diff log

(Add entries below as the design evolves.)

| Date | Author | Change | PR |
|---|---|---|---|
| 2026-05-21 | Jiwon | Initial design (PR-11 of Cognitive Phase 3) | (this PR) |
