diff --git a/docs/todos/2026-06-19-issue-312-graph-auto-stale-edges/plan.md b/docs/todos/2026-06-19-issue-312-graph-auto-stale-edges/plan.md
new file mode 100644
index 00000000..0af294ff
--- /dev/null
+++ b/docs/todos/2026-06-19-issue-312-graph-auto-stale-edges/plan.md
@@ -0,0 +1,446 @@
+# Graph Edge Auto-Stale Reactions Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make lifecycle graph edges automatically update target `Memory` rows through a deterministic `KV.graphEdges` state trigger.
+
+**Architecture:** Extend graph edge vocabulary with `supersedes`, `contradicts`, and `extends`, then handle active/inactive `KV.graphEdges` state changes in a focused reaction module. The handler resolves graph endpoints only through direct memory ids, explicit `GraphNode.properties.memoryId`, or graph node names that exactly match memory ids; it mutates only the target memory under a keyed lock, stores reaction bookkeeping in `KV.state`, and writes structured audit rows with the existing `relation_update` operation.
+
+**Tech Stack:** TypeScript ESM, iii-sdk `registerFunction`/`registerTrigger`, StateKV, `KV.state`, keyed mutex, Vitest.
+
+---
+
+## Sprint Contract
+
+Goal: Implement issue #312 for lifecycle graph-edge reactions.
+
+Scope:
+- Modify `src/types.ts`.
+- Modify `src/functions/graph-type-guards.ts`.
+- Modify `src/prompts/graph-extraction.ts`.
+- Create `src/functions/graph-edge-reactions.ts`.
+- Modify `src/triggers/events.ts`.
+- Modify `src/functions/graph.ts` for extraction-time lifecycle cycle rejection.
+- Add `test/graph-edge-reactions.test.ts`.
+- Update `test/events-boundary.test.ts` and `test/graph.test.ts`.
+- Update `docs/todos/2026-06-19-issue-312-graph-auto-stale-edges/todo.md`.
+
+Non-goals:
+- Do not add MCP tools, REST endpoints, dependencies, new KV scopes, migrations, auth changes, remotes, or package metadata changes.
+- Do not mutate source memories or create `KV.relations` from graph-edge reactions.
+- Do not alias temporal `succeeded_by` to memory `supersedes`.
+- Do not update the temporal graph prompt in this issue.
+- Do not use `GraphNode.sourceObservationIds` for memory endpoint resolution.
+- Do not use full `kv.list(KV.graphEdges)` scans in the reaction handler.
+
+Acceptance criteria:
+- `GraphEdgeType` and `isGraphEdgeType()` accept `supersedes`, `contradicts`, and `extends`.
+- `event::graph::edge::reaction` is registered with a state trigger on `KV.graphEdges`.
+- Active `supersedes` marks the target memory stale and decays strength by factor `0.5`, floored at `0.05`.
+- Active `contradicts` reduces target numeric `confidence` when present and target `strength`, floored at `0.05`.
+- Active `extends` adds source memory id to target `relatedIds` once.
+- State updates compare old-active and new-active lifecycle tuples: unapply old effects when an edge becomes inactive or changes tuple, apply new effects when a new active tuple appears, and skip no-op updates.
+- Delete/inactive events remove reaction-owned effects only after no remaining active edge in the same reaction group applies.
+- Missing, ambiguous, or cyclic endpoints skip safely and audit the reason.
+
+Intended verification:
+- Red targeted tests before production code.
+- Green targeted tests after implementation.
+- Lint, build, full non-integration test suite.
+- Semgrep and staged Gitleaks before commit/PR prep.
+
+Known boundaries:
+- Current-turn user request after the checkpoint discussion authorizes the graph edge vocabulary expansion required for issue #312.
+- Local branch is behind `origin/main` by 21 commits; use current local remote-tracking state until PR-prep needs base integration.
+- Remote fetch/push/PR/merge/archive remain separate workflow phases.
+
+## Feature / Verification Matrix
+
+| Change | Verification method | Status | Evidence |
+| --- | --- | --- | --- |
+| Lifecycle graph vocabulary | `test/graph.test.ts` lifecycle extraction test and compile | Passed | Targeted run passed: 3 files, 60 tests |
+| State trigger registration | `test/events-boundary.test.ts` trigger list/scope assertion | Passed | Targeted run passed: 3 files, 60 tests |
+| Create reaction semantics | `test/graph-edge-reactions.test.ts` create tests | Passed | Targeted run passed: 3 files, 60 tests |
+| Update/delete/un-reaction semantics | `test/graph-edge-reactions.test.ts` old-active/new-active and multi-edge delete tests | Passed | Targeted run passed: 3 files, 60 tests |
+| Missing/ambiguous/cycle safety | `test/graph-edge-reactions.test.ts` plus extraction cycle test | Passed | Targeted run passed: 3 files, 60 tests |
+| No full graph-edge scan in handler | KV mock fails on `list(KV.graphEdges)` in reaction tests | Passed | Reaction tests passed with graph-edge list failure enabled |
+| Concurrent same-target reactions | Clone-on-read `Promise.all` tests | Passed | Targeted run passed: 3 files, 60 tests |
+| Repo compatibility | lint, build, full tests, security gates | Passed with note | lint, build, full tests, Semgrep, and staged Gitleaks passed; one full-suite run under parallel load hit unrelated `codex-sdk-provider` timeouts before passing in isolation/rerun |
+
+## Files
+
+- Modify: `src/types.ts`
+- Modify: `src/functions/graph-type-guards.ts`
+- Modify: `src/prompts/graph-extraction.ts`
+- Create: `src/functions/graph-edge-reactions.ts`
+- Modify: `src/triggers/events.ts`
+- Modify: `src/functions/graph.ts`
+- Create: `test/graph-edge-reactions.test.ts`
+- Modify: `test/events-boundary.test.ts`
+- Modify: `test/graph.test.ts`
+- Update: `docs/todos/2026-06-19-issue-312-graph-auto-stale-edges/todo.md`
+
+## Task 1: Add Red Tests For Trigger Registration And Graph Vocabulary
+
+**Files:**
+- Modify: `test/events-boundary.test.ts`
+- Modify: `test/graph.test.ts`
+
+- [ ] **Step 1: Extend the event trigger registration expectation**
+
+Update the trigger list assertion to expect:
+
+```ts
+expect(sdk.triggers.map((trigger) => trigger.function_id)).toEqual([
+ "event::session::started",
+ "event::observation",
+ "event::session::stopped",
+ "event::session::ended",
+ "event::session::observation-count-changed",
+ "event::graph::edge::reaction",
+]);
+expect(sdk.triggers.at(-1)).toMatchObject({
+ type: "state",
+ config: { scope: KV.graphEdges },
+});
+```
+
+- [ ] **Step 2: Add a graph extraction lifecycle edge acceptance test**
+
+In `test/graph.test.ts`, add a test that sets `mockProvider.compress` to:
+
+```xml
+
+mem_source
+mem_target
+
+
+
+
+
+
+```
+
+Assert `edges.map((e) => e.type).sort()` equals `["contradicts", "extends", "supersedes"]`. Also assert the source and target graph nodes preserve `properties.memoryId`, so extracted lifecycle edges can be resolved through explicit endpoint metadata.
+
+- [ ] **Step 3: Add an extraction-time directed cycle rejection test**
+
+In `test/graph.test.ts`, emit `A supersedes B`, `B supersedes C`, and `C supersedes A` in the same extraction response, plus a separate non-cyclic `D extends E`. Assert the three cyclic `supersedes` edges are rejected and the non-cyclic `extends` edge persists. This chooses the explicit contract that every lifecycle edge participating in an in-batch directed cycle is rejected.
+
+- [ ] **Step 4: Run red tests**
+
+Run:
+
+```bash
+corepack pnpm exec vitest run --exclude test/integration.test.ts test/events-boundary.test.ts test/graph.test.ts
+```
+
+Expected: new tests fail because the trigger and lifecycle edge types do not exist.
+
+## Task 2: Add Red Tests For Graph Edge Reactions
+
+**Files:**
+- Create: `test/graph-edge-reactions.test.ts`
+
+- [ ] **Step 1: Add mock SDK/KV helpers**
+
+Use the mock style from `test/relations.test.ts` and `test/events-boundary.test.ts`: an in-memory `Map` store with `get`, `set`, `delete`, `list`, and `seed`; an SDK that stores registered functions/triggers. Add an option for the KV mock to throw if `list(KV.graphEdges)` is called.
+
+- [ ] **Step 2: Add memory, node, and edge fixtures**
+
+Add helpers:
+
+```ts
+function makeMemory(overrides: Partial & { id: string }): Memory {
+ return {
+ id: overrides.id,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ type: "fact",
+ title: "Memory",
+ content: "Memory content",
+ concepts: [],
+ files: [],
+ sessionIds: [],
+ strength: 0.8,
+ version: 1,
+ isLatest: true,
+ ...overrides,
+ };
+}
+```
+
+Use direct-memory-id edges for the first tests, and graph-node-id edges for endpoint resolution tests.
+
+- [ ] **Step 3: Add create reaction tests**
+
+Add tests proving:
+- `supersedes` active create/update sets target `isLatest` to `false` and `strength` to `0.4`.
+- `contradicts` active create/update reduces target `strength` by `sourceConfidence * 0.3` and floors at `0.05`; include a casted legacy `confidence` field and assert it decays too.
+- `extends` active create/update adds the source id to `target.relatedIds` and a duplicate event does not duplicate it.
+- graph-node endpoints resolve successfully through explicit `GraphNode.properties.memoryId`.
+- graph-node endpoints resolve successfully when graph node `name` is exactly a memory id.
+
+- [ ] **Step 4: Add safety and cycle tests**
+
+Add tests proving:
+- missing target memory returns `{ skipped: true }` and does not throw.
+- `GraphNode.sourceObservationIds` is ignored, even when it contains exactly one memory id.
+- an endpoint with no direct memory id, no `properties.memoryId`, and no memory-id name returns `{ skipped: true, reason: "missing_target_memory" }` or the matching source reason.
+- a direct self-cycle and a seeded reverse or three-node directed lifecycle path skip without target mutation.
+- unrelated/non-lifecycle incident graph edges do not create false positive cycles.
+- the handler does not call `kv.list(KV.graphEdges)`.
+
+- [ ] **Step 5: Add delete/un-reaction tests**
+
+Add tests proving:
+- two `supersedes` edges to the same target keep the target stale after deleting one and restore only after deleting the final edge.
+- two duplicate `extends` edges keep one `relatedIds` entry after deleting one and remove it after deleting the final edge only when it was not pre-existing.
+- if a target already had the related id before the first `extends`, deleting the final edge keeps it.
+
+- [ ] **Step 6: Add update/inactive event tests**
+
+Add tests proving:
+- `event_type: "update"` with `old_value: undefined` and active `new_value` applies a create reaction.
+- setting `new_value.stale = true` or `new_value.isLatest = false` unapplies the old active reaction.
+- changing type or endpoints in an update unapplies the old tuple and applies the new tuple.
+- an update where old/new active tuples are identical is a no-op.
+
+- [ ] **Step 7: Add concurrency/idempotency tests**
+
+Use a clone-on-read KV mock and `Promise.all` to prove concurrent same-target `extends` reactions do not lose `relatedIds` or active edge bookkeeping, and concurrent same-target `supersedes` reactions do not lose active edge ids.
+
+- [ ] **Step 8: Add audit assertions**
+
+Assert the audit scope receives `relation_update` rows with `functionId: "event::graph::edge::reaction"` for applied and skipped reactions. For unresolved skips, assert audit target ids use the edge id or unresolved endpoint id rather than a missing memory id.
+
+- [ ] **Step 9: Run red reaction tests**
+
+Run:
+
+```bash
+corepack pnpm exec vitest run --exclude test/integration.test.ts test/graph-edge-reactions.test.ts
+```
+
+Expected: test file fails to compile or fails because `graph-edge-reactions.ts` is not implemented.
+
+## Task 3: Implement Lifecycle Vocabulary And Extraction Cycle Guard
+
+**Files:**
+- Modify: `src/types.ts`
+- Modify: `src/functions/graph-type-guards.ts`
+- Modify: `src/prompts/graph-extraction.ts`
+- Modify: `src/functions/graph.ts`
+
+- [ ] **Step 1: Extend `GraphEdgeType`**
+
+Add to `GraphEdgeType` in `src/types.ts`:
+
+```ts
+ | "supersedes"
+ | "contradicts"
+ | "extends";
+```
+
+- [ ] **Step 2: Extend `GRAPH_EDGE_TYPES`**
+
+Add the same three strings to `GRAPH_EDGE_TYPES` in `src/functions/graph-type-guards.ts`.
+
+- [ ] **Step 3: Update graph extraction prompt**
+
+Change the relationship type list in `src/prompts/graph-extraction.ts` to include:
+
+```text
+supersedes|contradicts|extends
+```
+
+Add rules:
+
+```text
+- Use supersedes/contradicts/extends only for explicit memory lifecycle claims; do not infer them from vague relatedness.
+- For memory lifecycle relationships, include a property key="memoryId" on each endpoint entity when the exact memory id is present in the input.
+```
+
+- [ ] **Step 4: Add graph extraction cycle filtering**
+
+In `src/functions/graph.ts`, add lifecycle edge filtering near `parseGraphXml`. Build a directed adjacency over all in-batch lifecycle edges and reject every lifecycle edge whose target can reach its source through other in-batch lifecycle edges. Keep non-lifecycle edges and non-cyclic lifecycle edges.
+
+- [ ] **Step 5: Run graph tests**
+
+Run:
+
+```bash
+corepack pnpm exec vitest run --exclude test/integration.test.ts test/graph.test.ts
+```
+
+Expected: lifecycle vocabulary and cycle tests pass.
+
+## Task 4: Implement Graph Edge Reaction Handler
+
+**Files:**
+- Create: `src/functions/graph-edge-reactions.ts`
+- Modify: `src/triggers/events.ts`
+
+- [ ] **Step 1: Create reaction types and constants**
+
+Create `src/functions/graph-edge-reactions.ts` with:
+
+```ts
+import type { GraphEdge, GraphNode, Memory } from "../types.js";
+import { KV } from "../state/schema.js";
+import type { StateKV } from "../state/kv.js";
+import { withKeyedLock } from "../state/keyed-mutex.js";
+import { loadAdjacentEdgeIds } from "../state/graph-indexes.js";
+import { safeAudit } from "./audit.js";
+
+type GraphEdgeReactionType = "supersedes" | "contradicts" | "extends";
+const REACTION_TYPES = new Set([
+ "supersedes",
+ "contradicts",
+ "extends",
+]);
+const STRENGTH_FLOOR = 0.05;
+const CYCLE_VISIT_LIMIT = 500;
+```
+
+- [ ] **Step 2: Add payload narrowing and active-state classification**
+
+Add a `GraphEdgeStateEvent` type and guards that accept only object payloads with `event_type`, `old_value`, and `new_value`. Treat `new_value`/`old_value` as `unknown` and narrow with a local `isGraphEdgeLike()`.
+
+Define active lifecycle state as: lifecycle type, not `stale`, and `isLatest !== false`. For every payload, calculate old active and new active tuples. If old and new active tuples are identical, return a no-op. If old exists and differs, unapply it. If new exists and differs, apply it.
+
+- [ ] **Step 3: Add endpoint resolution**
+
+Implement `resolveMemoryId(kv, endpointId, role)`:
+- first `kv.get(KV.memories, endpointId)`;
+- then `kv.get(KV.graphNodes, endpointId)` and try `node.properties.memoryId`;
+- then try `node.name`;
+- never use `node.sourceObservationIds`;
+- return a role-specific missing reason when no candidate exists;
+- audit unresolved skips using `[edge.id]` or `[edge.targetNodeId]` rather than unresolved memory ids.
+
+- [ ] **Step 4: Add reaction state records in `KV.state`**
+
+Use keys:
+
+```ts
+const edgeStateKey = (edgeId: string) => `graph-edge-reaction:edge:${edgeId}`;
+const groupStateKey = (groupKey: string) => `graph-edge-reaction:group:${groupKey}`;
+```
+
+Use group keys:
+- `supersedes:${targetMemoryId}`
+- `contradicts:${targetMemoryId}`
+- `extends:${targetMemoryId}:${sourceMemoryId}`
+
+Store base values, active edge ids, and last applied reaction-owned values.
+
+- [ ] **Step 5: Implement create/apply reactions**
+
+Under `withKeyedLock(\`graph-edge-reaction:${targetMemoryId}\`, ...)`:
+- skip duplicate edge ids;
+- skip self/cycle;
+- for `supersedes`, snapshot base then set `isLatest=false` and `strength=max(0.05, baseStrength * 0.5)`;
+- for `contradicts`, compute `sourceConfidence` from numeric source `confidence`, else edge weight, else source strength normalized to 0-1; subtract `sourceConfidence * 0.3` from target numeric confidence when present and from target strength;
+- for `extends`, add source id if not already present and remember whether it was pre-existing.
+
+- [ ] **Step 6: Implement unapply reactions**
+
+On unapply, read the edge state, remove the edge id from the group, and:
+- if other group edge ids remain, keep the effect;
+- if final, restore only fields whose current values still match the last reaction-owned values;
+- for `extends`, remove `sourceMemoryId` only when the handler added it.
+
+- [ ] **Step 7: Implement defensive directed cycle check**
+
+For lifecycle edges, reject self cycles. For graph-level directed cycles, use `loadAdjacentEdgeIds(kv, nodeId)` plus targeted `kv.get(KV.graphEdges, edgeId)` reads. Traverse only active lifecycle edges in outbound direction `sourceNodeId -> targetNodeId`; ignore non-lifecycle edges and inbound-only incident edges. Do not call `kv.list(KV.graphEdges)`.
+
+- [ ] **Step 8: Audit every reaction attempt**
+
+Use resolved memory ids when available and edge/endpoint ids for unresolved skips:
+
+```ts
+await safeAudit(kv, "relation_update", "event::graph::edge::reaction", auditTargetIds, {
+ action,
+ reason,
+ edgeId: edge.id,
+ edgeType: edge.type,
+ sourceMemoryId,
+ targetMemoryId,
+ groupKey,
+});
+```
+
+- [ ] **Step 9: Register the event function and trigger**
+
+In `src/triggers/events.ts`, import `handleGraphEdgeReactionEvent` and register:
+
+```ts
+sdk.registerFunction("event::graph::edge::reaction", async (payload: unknown) =>
+ handleGraphEdgeReactionEvent(kv, payload),
+);
+sdk.registerTrigger({
+ type: "state",
+ function_id: "event::graph::edge::reaction",
+ config: { scope: KV.graphEdges },
+});
+```
+
+- [ ] **Step 10: Run reaction tests**
+
+Run:
+
+```bash
+corepack pnpm exec vitest run --exclude test/integration.test.ts test/graph-edge-reactions.test.ts test/events-boundary.test.ts
+```
+
+Expected: reaction and trigger tests pass.
+
+## Task 5: Integrated Verification And Task Record Update
+
+**Files:**
+- Update: `docs/todos/2026-06-19-issue-312-graph-auto-stale-edges/todo.md`
+
+- [ ] **Step 1: Run targeted combined tests**
+
+Run:
+
+```bash
+corepack pnpm exec vitest run --exclude test/integration.test.ts test/graph-edge-reactions.test.ts test/events-boundary.test.ts test/graph.test.ts
+```
+
+Expected: all targeted tests pass.
+
+- [ ] **Step 2: Run broader project checks**
+
+Run:
+
+```bash
+corepack pnpm run lint
+corepack pnpm run build
+corepack pnpm test
+```
+
+Expected: all pass, or blockers are recorded with nearest targeted evidence.
+
+- [ ] **Step 3: Run security gates before commit/PR prep**
+
+Run:
+
+```bash
+semgrep scan --config p/default --error --metrics=off .
+gitleaks protect --staged --redact
+```
+
+Expected: both pass. If tools are missing or findings appear, stop and record the blocker.
+
+- [ ] **Step 4: Update task record**
+
+Record final verification evidence, Sprint Contract status, Feature / Verification Matrix status, review notes, and residual risks in `docs/todos/2026-06-19-issue-312-graph-auto-stale-edges/todo.md`.
+
+## Self-Review
+
+- Spec coverage: issue acceptance, arena synthesis, graph vocabulary, state trigger, reaction semantics, update/delete un-reaction, cycle safety, tests, and verification are covered.
+- Placeholder scan: no `TBD`, broad placeholder, or docs/superpowers output path remains.
+- Type consistency: plan uses existing `GraphEdge`, `GraphNode`, `Memory`, `KV`, `StateKV`, `safeAudit`, `withKeyedLock`, and `relation_update` operation.
+- Boundary check: no dependency, auth, MCP/REST count, package metadata, temporal prompt, or migration change is planned. The approved boundary expansion is limited to accepted graph edge vocabulary.
diff --git a/docs/todos/2026-06-19-issue-312-graph-auto-stale-edges/todo.md b/docs/todos/2026-06-19-issue-312-graph-auto-stale-edges/todo.md
new file mode 100644
index 00000000..e8a28b28
--- /dev/null
+++ b/docs/todos/2026-06-19-issue-312-graph-auto-stale-edges/todo.md
@@ -0,0 +1,139 @@
+# Issue 312 Graph Edge Auto-Stale Reactions
+
+Scope: GitHub issue #312 in branch `issue/312-graph-auto-stale-edges`.
+
+## Validity Evidence
+
+- Worktree: `/Users/A1538552/.codex/worktrees/0c8e/agentmemory`.
+- Branch: `issue/312-graph-auto-stale-edges`, initially created from local `origin/main` at `499b53fc`.
+- Current status before implementation planning: clean branch, local `origin/main` is 21 commits ahead of the branch; after remote tracking moved, the branch reports 26 commits behind `origin/main`.
+- Remote boundary: target only `origin` (`https://github.com/wbugitlab1/agentmemory.git`); do not target `upstream` (`https://github.com/rohitg00/agentmemory.git`).
+- Issue #312 is open and requests `KV.graphEdges` state-trigger reactions for `supersedes`, `contradicts`, and `extends`.
+- `src/triggers/events.ts` has the existing state-trigger pattern on `KV.sessions`.
+- `src/functions/graph.ts` and `src/functions/temporal-graph.ts` write `KV.graphEdges`, but no graph-edge reaction handler exists.
+- `src/types.ts` currently excludes `supersedes`, `contradicts`, and `extends` from `GraphEdgeType`; `src/functions/graph-type-guards.ts` and graph extraction prompts reject/drop those lifecycle edge types.
+- `src/functions/remember.ts` and `src/functions/relations.ts` contain inline/direct memory supersession behavior, but graph-edge writes do not mutate target `Memory` rows.
+
+## Sprint Contract
+
+Goal: Implement issue #312 so persisted lifecycle graph edges trigger deterministic target-memory reactions.
+
+Scope:
+- Add lifecycle graph edge vocabulary for `supersedes`, `contradicts`, and `extends`.
+- Add an internal `event::graph::edge::reaction` state-trigger handler on `KV.graphEdges`.
+- Add focused tests for graph edge vocabulary, trigger registration, create/delete reactions, dedupe, missing targets, cycle rejection, and multi-edge un-reaction.
+- Keep reaction bookkeeping in existing `KV.state`.
+- Resolve graph endpoints only through direct memory ids, explicit `GraphNode.properties.memoryId`, or graph node names that exactly match memory ids. Do not infer memory identity from merged `sourceObservationIds`.
+
+Non-goals:
+- No MCP tool count changes, REST endpoint additions, auth changes, dependency changes, package metadata changes, migrations, or new external services.
+- No `upstream` remote usage.
+- No remote push, PR creation, PR merge, issue closure, or thread archival until green verification and the remote-write phase.
+
+Acceptance criteria:
+- `GraphEdgeType` and graph extraction allow `supersedes`, `contradicts`, and `extends` lifecycle edges.
+- `event::graph::edge::reaction` is registered as a state trigger over `KV.graphEdges`.
+- On create, `supersedes` marks the target memory `isLatest: false` and decays strength.
+- On create, `contradicts` decays target confidence when present and target strength as the local memory score, floored at `0.05`.
+- On create, `extends` adds the source memory id to target `relatedIds` with set semantics.
+- Delete/un-reaction is deterministic and only restores after no remaining same reaction group edge still applies.
+- Missing or ambiguous memory endpoints skip safely with audit evidence.
+- Cyclic lifecycle edges are rejected at extraction where possible and defensively skipped by the handler.
+
+Intended verification:
+- Red/green targeted Vitest tests for new behavior.
+- `corepack pnpm exec vitest run --exclude test/integration.test.ts test/graph-edge-reactions.test.ts test/events-boundary.test.ts test/graph.test.ts`.
+- `corepack pnpm run lint`.
+- `corepack pnpm run build`.
+- `corepack pnpm test`.
+- Required security gates before commit/PR prep because this changes state-trigger behavior, protocol/schema handling, persistence, and agent workflow behavior: Semgrep and staged Gitleaks; OSV only if dependency/package surfaces change.
+
+Known boundaries:
+- Current-turn user request invoked `$github-feature-loop` after approving the recommended full approach; assumption: graph edge vocabulary expansion is approved for issue acceptance.
+- Branch is behind local `origin/main` by 21 commits; no fetch/pull has run in this turn.
+- `github-push-prepare` tool/skill was not discoverable through `tool_search`; local branch-prep steps may need a documented fallback or user checkpoint later.
+
+Stop conditions:
+- Endpoint mapping from graph edge endpoints to memories cannot be made conservative and deterministic.
+- A required fix expands auth, REST/MCP surfaces, dependencies, migrations, remote state, or package metadata beyond the approved graph vocabulary change.
+- Required verification is blocked and no targeted alternative covers the changed surface.
+
+## Feature / Verification Matrix
+
+| Change | Verification method | Status | Evidence |
+| --- | --- | --- | --- |
+| Lifecycle graph vocabulary | Red/green tests in `test/graph.test.ts` and type/guard compile | Passed | `corepack pnpm exec vitest run --exclude test/integration.test.ts test/events-boundary.test.ts test/graph.test.ts test/graph-edge-reactions.test.ts`: 60 passed |
+| State trigger registration | `test/events-boundary.test.ts` asserts `KV.graphEdges` trigger | Passed | Same targeted run: 60 passed |
+| Create reactions | `test/graph-edge-reactions.test.ts` covers `supersedes`, `contradicts`, `extends` | Passed | Same targeted run: 60 passed |
+| Delete/un-reaction | `test/graph-edge-reactions.test.ts` covers duplicate and multi-edge delete | Passed | Same targeted run: 60 passed |
+| Missing/ambiguous/cyclic safety | `test/graph-edge-reactions.test.ts` and `test/graph.test.ts` | Passed | Same targeted run: 60 passed |
+| Update/stale event semantics | `test/graph-edge-reactions.test.ts` old-active/new-active tests | Passed | Same targeted run: 60 passed |
+| No full graph-edge scan in handler | KV mock fails on `list(KV.graphEdges)` during reaction tests | Passed | Reaction tests passed with `failOnGraphEdgeList: true` |
+| Concurrent same-target reactions | Clone-on-read KV `Promise.all` tests | Passed | Same targeted run: 60 passed |
+| Full repo compatibility | lint, build, full tests | Passed with note | `corepack pnpm run lint`; `corepack pnpm test`: 208 files/2845 tests passed on isolated rerun; `corepack pnpm run build` passed |
+
+## Subagent Ledger
+
+| Workstream | Scope | Edits allowed | Expected output | Result | Residual risk |
+| --- | --- | --- | --- | --- | --- |
+| Arena candidate 1 | Implementation strategy | No repo edits | Strategy and rationale | Recommended state trigger + `KV.state` reaction groups | Endpoint mapping still needs careful implementation |
+| Arena candidate 2 | Implementation strategy | No repo edits | Strategy and rationale | Recommended explicit graph vocabulary checkpoint and exact-one endpoint rule | Proposed new KV scope/audit op rejected |
+| Arena candidate 3 | Implementation strategy | No repo edits | Strategy and rationale | Added untrusted payload narrowing and concurrency/idempotency test ideas | GraphNode staling model rejected |
+| Arena judge | Candidate comparison | No repo edits | Scores and base recommendation | Recommended Candidate 1 as base with grafts from 2/3 | Parent must implement and verify |
+| Pre-implementation spec/acceptance review | Plan and task record | No repo edits | High/Medium findings or ACCEPT | Found missing update/stale semantics and positive GraphNode endpoint coverage | Plan patched before implementation |
+| Pre-implementation architecture review | Plan and graph integration | No repo edits | High/Medium findings or ACCEPT | Found cycle contract mismatch, unsafe provenance resolution, temporal scope creep, and skip-audit target issue | Plan patched before implementation |
+| Pre-implementation verification review | Plan and tests | No repo edits | High/Medium findings or ACCEPT | Found missing extracted endpoint, no-scan, cycle, and concurrency coverage | Plan patched before implementation |
+
+## Arena Synthesis
+
+Synthesis file: `/tmp/arena-issue-312/synthesis.md`.
+
+Base: Candidate 1. Grafts: Candidate 2's explicit graph-vocabulary checkpoint and endpoint ambiguity rule, plus Candidate 3's untrusted payload narrowing and concurrency/idempotency tests.
+
+Rejected: GraphNode staling/snapshot rewrite, new public API surface, new audit operation by default, full graph scans in hot handler, and temporal `succeeded_by` aliasing.
+
+## Progress
+
+- Read repo instructions and relevant workflow skills.
+- Created/switch branch `issue/312-graph-auto-stale-edges`.
+- Validated issue #312 from GitHub issue data and local source evidence.
+- Ran arena and cross-judge. Candidate 1 selected as base.
+- Created this task record and implementation plan before production code edits.
+- Ran pre-implementation plan review with three read-only subagents.
+- Triage:
+ - Fixed plan gap for update/stale events by requiring old-active/new-active semantics and tests.
+ - Fixed endpoint-resolution risk by forbidding `sourceObservationIds` inference and requiring positive `GraphNode.properties.memoryId`/name tests.
+ - Fixed cycle mismatch by choosing "drop all lifecycle edges participating in an in-batch directed cycle" and adding 3-node directed cycle tests.
+ - Fixed temporal scope creep by removing temporal prompt changes from this issue.
+ - Fixed no-scan/concurrency gaps by adding explicit tests.
+- Implemented lifecycle graph edge vocabulary in `src/types.ts`, `src/functions/graph-type-guards.ts`, and `src/prompts/graph-extraction.ts`.
+- Implemented extraction-time lifecycle cycle rejection in `src/functions/graph.ts`.
+- Implemented `src/functions/graph-edge-reactions.ts` with conservative endpoint resolution, keyed target-memory locking, `KV.state` reaction groups, no `KV.graphEdges` full scan, cycle skip checks, and `relation_update` audit rows.
+- Registered `event::graph::edge::reaction` as a `KV.graphEdges` state trigger in `src/triggers/events.ts`.
+- Added targeted regression tests in `test/graph-edge-reactions.test.ts`, `test/events-boundary.test.ts`, and `test/graph.test.ts`.
+- Verification performed:
+ - Initial red run failed as expected: missing `event::graph::edge::reaction`, lifecycle graph types dropped, reaction handler absent.
+ - `corepack pnpm install --frozen-lockfile --ignore-scripts` was required after pnpm ignored-build hardening blocked the first `pnpm exec`; an unwanted generated `allowBuilds` placeholder in `pnpm-workspace.yaml` was removed.
+ - `corepack pnpm exec vitest run --exclude test/integration.test.ts test/events-boundary.test.ts test/graph.test.ts test/graph-edge-reactions.test.ts` passed: 3 files, 60 tests.
+ - `corepack pnpm run lint` passed.
+ - `corepack pnpm test` passed on isolated rerun: 208 files, 2845 tests.
+ - `corepack pnpm run build` passed; emitted existing bundle/source-map/performance warnings only.
+ - `semgrep scan --config p/default --error --metrics=off .` passed with 0 findings.
+ - `gitleaks protect --staged --redact` passed with no leaks after staging the final intended index.
+
+## Implementation Review
+
+- Read-only adversarial review found two important issues:
+ - Graph-node endpoint resolution treated `properties.memoryId` as authoritative even when the graph node `name` also matched a different memory id.
+ - Final `extends` unapply could remove a `relatedIds` entry that another writer had independently preserved while the edge was active.
+- Fixes applied:
+ - Graph-node endpoint resolution now collects existing-memory candidates from `properties.memoryId` and exact `name`; conflicting candidates skip with `ambiguous_source_memory` or `ambiguous_target_memory` and audit details.
+ - `extends` reaction groups now snapshot base and last-applied `relatedIds`; final unapply restores only when the current `relatedIds` and `updatedAt` still match the reaction-owned state.
+ - Regression tests added for conflicting endpoint candidates and externally preserved `relatedIds`.
+- A full-suite run performed in parallel with lint failed two unrelated `test/codex-sdk-provider.test.ts` tests by hitting their 2s timeout; the same file passed in isolation, and the full suite passed when rerun without concurrent checks. This is recorded as a transient verification risk and should block remote automation without human checkpoint approval.
+
+## Review Notes
+
+- The implementation stores reaction bookkeeping in existing `KV.state`, not a new KV scope.
+- No MCP tools, REST endpoints, dependency files, package metadata, auth behavior, migrations, or remote configuration were changed.
+- Security gates completed: Semgrep passed with 0 findings; staged Gitleaks passed with no leaks.
diff --git a/src/functions/graph-edge-reactions.ts b/src/functions/graph-edge-reactions.ts
new file mode 100644
index 00000000..1f52b2b2
--- /dev/null
+++ b/src/functions/graph-edge-reactions.ts
@@ -0,0 +1,609 @@
+import type { GraphEdge, GraphNode, Memory } from "../types.js";
+import { KV } from "../state/schema.js";
+import type { StateKV } from "../state/kv.js";
+import { loadAdjacentEdgeIds } from "../state/graph-indexes.js";
+import { withKeyedLock } from "../state/keyed-mutex.js";
+import { safeAudit } from "./audit.js";
+
+type GraphEdgeReactionType = "supersedes" | "contradicts" | "extends";
+type MemoryWithConfidence = Memory & { confidence?: number };
+
+type ActiveEdge = {
+ id: string;
+ type: GraphEdgeReactionType;
+ sourceNodeId: string;
+ targetNodeId: string;
+ edge: GraphEdge;
+};
+
+type ResolvedEndpoint =
+ | { status: "resolved"; memoryId: string }
+ | { status: "missing" }
+ | { status: "ambiguous"; memoryIds: string[] };
+
+type EdgeReactionState = {
+ version: 1;
+ edgeId: string;
+ groupKey: string;
+ type: GraphEdgeReactionType;
+ sourceMemoryId: string;
+ targetMemoryId: string;
+};
+
+type ReactionGroupState = {
+ version: 1;
+ type: GraphEdgeReactionType;
+ sourceMemoryId?: string;
+ targetMemoryId: string;
+ activeEdgeIds: string[];
+ baseIsLatest?: boolean;
+ baseStrength?: number;
+ baseConfidence?: number;
+ lastAppliedIsLatest?: boolean;
+ lastAppliedStrength?: number;
+ lastAppliedConfidence?: number;
+ baseRelatedIds?: string[];
+ lastAppliedRelatedIds?: string[];
+ lastAppliedUpdatedAt?: string;
+ relatedIdWasPresent?: boolean;
+ addedRelatedId?: string;
+};
+
+const REACTION_TYPES = new Set([
+ "supersedes",
+ "contradicts",
+ "extends",
+]);
+const FUNCTION_ID = "event::graph::edge::reaction";
+const STRENGTH_FLOOR = 0.05;
+const CYCLE_VISIT_LIMIT = 500;
+
+function isRecord(value: unknown): value is Record {
+ return !!value && typeof value === "object";
+}
+
+function isReactionType(value: unknown): value is GraphEdgeReactionType {
+ return typeof value === "string" && REACTION_TYPES.has(value);
+}
+
+function isFiniteNumber(value: unknown): value is number {
+ return typeof value === "number" && Number.isFinite(value);
+}
+
+function clampStrength(value: number): number {
+ return Math.max(STRENGTH_FLOOR, value);
+}
+
+function readPayloadValue(payload: Record, key: string): unknown {
+ if (key in payload) return payload[key];
+ return key === "old_value" ? payload["old_val"] : payload["new_val"];
+}
+
+function isGraphEdgeLike(value: unknown): value is GraphEdge {
+ return (
+ isRecord(value) &&
+ typeof value.id === "string" &&
+ typeof value.sourceNodeId === "string" &&
+ typeof value.targetNodeId === "string" &&
+ typeof value.type === "string"
+ );
+}
+
+function activeEdgeFrom(value: unknown): ActiveEdge | null {
+ if (!isGraphEdgeLike(value) || !isReactionType(value.type)) return null;
+ if (value.stale || value.isLatest === false) return null;
+ return {
+ id: value.id,
+ type: value.type,
+ sourceNodeId: value.sourceNodeId,
+ targetNodeId: value.targetNodeId,
+ edge: value,
+ };
+}
+
+function isSameActiveEdge(a: ActiveEdge | null, b: ActiveEdge | null): boolean {
+ return (
+ !!a &&
+ !!b &&
+ a.id === b.id &&
+ a.type === b.type &&
+ a.sourceNodeId === b.sourceNodeId &&
+ a.targetNodeId === b.targetNodeId
+ );
+}
+
+function edgeStateKey(edgeId: string): string {
+ return `graph-edge-reaction:edge:${edgeId}`;
+}
+
+function groupStateKey(groupKey: string): string {
+ return `graph-edge-reaction:group:${groupKey}`;
+}
+
+function reactionGroupKey(
+ type: GraphEdgeReactionType,
+ sourceMemoryId: string,
+ targetMemoryId: string,
+): string {
+ if (type === "extends") return `${type}:${targetMemoryId}:${sourceMemoryId}`;
+ return `${type}:${targetMemoryId}`;
+}
+
+async function auditReaction(
+ kv: StateKV,
+ edgeId: string,
+ targetIds: string[],
+ details: Record,
+): Promise {
+ await safeAudit(kv, "relation_update", FUNCTION_ID, targetIds, {
+ edgeId,
+ ...details,
+ });
+}
+
+async function resolveEndpoint(
+ kv: StateKV,
+ endpointId: string,
+): Promise {
+ const direct = await kv.get(KV.memories, endpointId);
+ if (direct) return { status: "resolved", memoryId: endpointId };
+
+ const node = await kv.get(KV.graphNodes, endpointId);
+ if (!node) return { status: "missing" };
+
+ const candidates = new Set();
+ const memoryId = node.properties?.memoryId;
+ if (typeof memoryId === "string" && memoryId) {
+ const memory = await kv.get(KV.memories, memoryId);
+ if (memory) candidates.add(memoryId);
+ }
+
+ if (typeof node.name === "string" && node.name) {
+ const memory = await kv.get(KV.memories, node.name);
+ if (memory) candidates.add(node.name);
+ }
+
+ if (candidates.size === 1) {
+ return { status: "resolved", memoryId: [...candidates][0] };
+ }
+ if (candidates.size > 1) {
+ return { status: "ambiguous", memoryIds: [...candidates] };
+ }
+ return { status: "missing" };
+}
+
+function sourceConfidence(source: MemoryWithConfidence, edge: GraphEdge): number {
+ if (isFiniteNumber(source.confidence)) return Math.max(0, Math.min(1, source.confidence));
+ if (isFiniteNumber(edge.weight)) return Math.max(0, Math.min(1, edge.weight));
+ if (isFiniteNumber(source.strength)) return Math.max(0, Math.min(1, source.strength));
+ return 0.5;
+}
+
+function readActiveIds(group: ReactionGroupState): string[] {
+ return Array.isArray(group.activeEdgeIds) ? group.activeEdgeIds : [];
+}
+
+function sameStringArray(a: string[] | undefined, b: string[] | undefined): boolean {
+ if (!a || !b || a.length !== b.length) return false;
+ return a.every((value, index) => value === b[index]);
+}
+
+function buildGroupState(
+ type: GraphEdgeReactionType,
+ sourceMemoryId: string,
+ targetMemoryId: string,
+ target: MemoryWithConfidence,
+): ReactionGroupState {
+ return {
+ version: 1,
+ type,
+ ...(type === "extends" ? { sourceMemoryId } : {}),
+ targetMemoryId,
+ activeEdgeIds: [],
+ baseIsLatest: target.isLatest,
+ baseStrength: target.strength,
+ ...(isFiniteNumber(target.confidence) ? { baseConfidence: target.confidence } : {}),
+ };
+}
+
+async function createsDirectedLifecycleCycle(
+ kv: StateKV,
+ edge: ActiveEdge,
+): Promise {
+ if (edge.sourceNodeId === edge.targetNodeId) return true;
+
+ const visited = new Set();
+ const queue = [edge.targetNodeId];
+ while (queue.length > 0) {
+ if (visited.size >= CYCLE_VISIT_LIMIT) return true;
+ const nodeId = queue.shift()!;
+ if (nodeId === edge.sourceNodeId) return true;
+ if (visited.has(nodeId)) continue;
+ visited.add(nodeId);
+
+ const edgeIds = await loadAdjacentEdgeIds(kv, nodeId);
+ for (const edgeId of edgeIds) {
+ if (edgeId === edge.id) continue;
+ const adjacent = await kv.get(KV.graphEdges, edgeId);
+ if (!isGraphEdgeLike(adjacent) || !isReactionType(adjacent.type)) continue;
+ if (adjacent.stale || adjacent.isLatest === false) continue;
+ if (adjacent.sourceNodeId !== nodeId) continue;
+ if (!visited.has(adjacent.targetNodeId)) queue.push(adjacent.targetNodeId);
+ }
+ }
+
+ return false;
+}
+
+async function applySupersedes(
+ kv: StateKV,
+ sourceMemoryId: string,
+ targetMemoryId: string,
+ target: MemoryWithConfidence,
+ group: ReactionGroupState,
+): Promise {
+ const activeIds = readActiveIds(group);
+ if (activeIds.length > 0) return;
+
+ const nextStrength = clampStrength(target.strength * 0.5);
+ const updated: MemoryWithConfidence = {
+ ...target,
+ isLatest: false,
+ strength: nextStrength,
+ updatedAt: new Date().toISOString(),
+ };
+ await kv.set(KV.memories, targetMemoryId, updated);
+ group.sourceMemoryId = sourceMemoryId;
+ group.lastAppliedIsLatest = updated.isLatest;
+ group.lastAppliedStrength = updated.strength;
+ group.relatedIdWasPresent = undefined;
+ group.addedRelatedId = undefined;
+}
+
+async function applyContradicts(
+ kv: StateKV,
+ edge: ActiveEdge,
+ source: MemoryWithConfidence,
+ targetMemoryId: string,
+ target: MemoryWithConfidence,
+ group: ReactionGroupState,
+): Promise {
+ const activeIds = readActiveIds(group);
+ if (activeIds.length > 0) return;
+
+ const decay = sourceConfidence(source, edge.edge) * 0.3;
+ const updated: MemoryWithConfidence = {
+ ...target,
+ strength: clampStrength(target.strength - decay),
+ updatedAt: new Date().toISOString(),
+ };
+ if (isFiniteNumber(target.confidence)) {
+ updated.confidence = clampStrength(target.confidence - decay);
+ }
+ await kv.set(KV.memories, targetMemoryId, updated);
+ group.lastAppliedStrength = updated.strength;
+ if (isFiniteNumber(updated.confidence)) {
+ group.lastAppliedConfidence = updated.confidence;
+ }
+}
+
+async function applyExtends(
+ kv: StateKV,
+ sourceMemoryId: string,
+ targetMemoryId: string,
+ target: MemoryWithConfidence,
+ group: ReactionGroupState,
+): Promise {
+ const activeIds = readActiveIds(group);
+ if (activeIds.length > 0) return;
+
+ const relatedIds = Array.isArray(target.relatedIds) ? [...target.relatedIds] : [];
+ const alreadyRelated = relatedIds.includes(sourceMemoryId);
+ group.relatedIdWasPresent = alreadyRelated;
+ group.baseRelatedIds = [...relatedIds];
+ if (alreadyRelated) return;
+
+ relatedIds.push(sourceMemoryId);
+ const updatedAt = new Date().toISOString();
+ await kv.set(KV.memories, targetMemoryId, {
+ ...target,
+ relatedIds,
+ updatedAt,
+ });
+ group.addedRelatedId = sourceMemoryId;
+ group.lastAppliedRelatedIds = [...relatedIds];
+ group.lastAppliedUpdatedAt = updatedAt;
+}
+
+async function applyActiveEdge(
+ kv: StateKV,
+ active: ActiveEdge,
+): Promise> {
+ const [source, target] = await Promise.all([
+ resolveEndpoint(kv, active.sourceNodeId),
+ resolveEndpoint(kv, active.targetNodeId),
+ ]);
+ if (source.status === "ambiguous") {
+ await auditReaction(kv, active.id, [active.id], {
+ type: active.type,
+ action: "skip",
+ reason: "ambiguous_source_memory",
+ memoryIds: source.memoryIds,
+ });
+ return { skipped: true, reason: "ambiguous_source_memory" };
+ }
+ if (source.status === "missing") {
+ await auditReaction(kv, active.id, [active.id], {
+ type: active.type,
+ action: "skip",
+ reason: "missing_source_memory",
+ });
+ return { skipped: true, reason: "missing_source_memory" };
+ }
+ if (target.status === "ambiguous") {
+ await auditReaction(kv, active.id, [active.id], {
+ type: active.type,
+ action: "skip",
+ reason: "ambiguous_target_memory",
+ memoryIds: target.memoryIds,
+ });
+ return { skipped: true, reason: "ambiguous_target_memory" };
+ }
+ if (target.status === "missing") {
+ await auditReaction(kv, active.id, [active.id], {
+ type: active.type,
+ action: "skip",
+ reason: "missing_target_memory",
+ });
+ return { skipped: true, reason: "missing_target_memory" };
+ }
+ if (
+ source.memoryId === target.memoryId ||
+ (await createsDirectedLifecycleCycle(kv, active))
+ ) {
+ await auditReaction(kv, active.id, [active.id, target.memoryId], {
+ type: active.type,
+ action: "skip",
+ reason: "cycle_detected",
+ });
+ return { skipped: true, reason: "cycle_detected" };
+ }
+
+ return withKeyedLock(`graph-edge-reaction:${target.memoryId}`, async () => {
+ const targetMemory = await kv.get(
+ KV.memories,
+ target.memoryId,
+ );
+ const sourceMemory = await kv.get(
+ KV.memories,
+ source.memoryId,
+ );
+ if (!targetMemory || !sourceMemory) {
+ await auditReaction(kv, active.id, [active.id], {
+ type: active.type,
+ action: "skip",
+ reason: "missing_memory",
+ });
+ return { skipped: true, reason: "missing_memory" };
+ }
+
+ const groupKey = reactionGroupKey(
+ active.type,
+ source.memoryId,
+ target.memoryId,
+ );
+ const existingEdgeState = await kv.get(
+ KV.state,
+ edgeStateKey(active.id),
+ );
+ if (existingEdgeState?.groupKey === groupKey) {
+ await auditReaction(kv, active.id, [active.id, target.memoryId], {
+ type: active.type,
+ action: "skip",
+ reason: "already_applied",
+ });
+ return { skipped: true, reason: "already_applied" };
+ }
+
+ let group =
+ (await kv.get(KV.state, groupStateKey(groupKey))) ??
+ buildGroupState(active.type, source.memoryId, target.memoryId, targetMemory);
+
+ if (active.type === "supersedes") {
+ await applySupersedes(kv, source.memoryId, target.memoryId, targetMemory, group);
+ } else if (active.type === "contradicts") {
+ await applyContradicts(kv, active, sourceMemory, target.memoryId, targetMemory, group);
+ } else {
+ await applyExtends(kv, source.memoryId, target.memoryId, targetMemory, group);
+ }
+
+ group = {
+ ...group,
+ activeEdgeIds: [...new Set([...readActiveIds(group), active.id])],
+ };
+ await kv.set(KV.state, groupStateKey(groupKey), group);
+ await kv.set(KV.state, edgeStateKey(active.id), {
+ version: 1,
+ edgeId: active.id,
+ groupKey,
+ type: active.type,
+ sourceMemoryId: source.memoryId,
+ targetMemoryId: target.memoryId,
+ });
+ await auditReaction(kv, active.id, [active.id, target.memoryId], {
+ type: active.type,
+ action: "apply",
+ sourceMemoryId: source.memoryId,
+ targetMemoryId: target.memoryId,
+ });
+ return { applied: true, type: active.type };
+ });
+}
+
+function restoreIfEqual(
+ current: T,
+ applied: T | undefined,
+ base: T | undefined,
+): T {
+ return applied !== undefined && current === applied && base !== undefined
+ ? base
+ : current;
+}
+
+async function unapplyFinalGroup(
+ kv: StateKV,
+ group: ReactionGroupState,
+): Promise {
+ const target = await kv.get(KV.memories, group.targetMemoryId);
+ if (!target) return;
+
+ if (group.type === "extends" && group.addedRelatedId && !group.relatedIdWasPresent) {
+ const currentRelatedIds = target.relatedIds ?? [];
+ if (
+ !sameStringArray(currentRelatedIds, group.lastAppliedRelatedIds) ||
+ target.updatedAt !== group.lastAppliedUpdatedAt
+ ) {
+ return;
+ }
+ await kv.set(KV.memories, group.targetMemoryId, {
+ ...target,
+ relatedIds: group.baseRelatedIds ?? [],
+ updatedAt: new Date().toISOString(),
+ });
+ return;
+ }
+
+ const updated: MemoryWithConfidence = {
+ ...target,
+ updatedAt: new Date().toISOString(),
+ };
+ if (group.type === "supersedes") {
+ updated.isLatest = restoreIfEqual(
+ target.isLatest,
+ group.lastAppliedIsLatest,
+ group.baseIsLatest,
+ );
+ updated.strength = restoreIfEqual(
+ target.strength,
+ group.lastAppliedStrength,
+ group.baseStrength,
+ );
+ }
+ if (group.type === "contradicts") {
+ updated.strength = restoreIfEqual(
+ target.strength,
+ group.lastAppliedStrength,
+ group.baseStrength,
+ );
+ if (isFiniteNumber(target.confidence)) {
+ updated.confidence = restoreIfEqual(
+ target.confidence,
+ group.lastAppliedConfidence,
+ group.baseConfidence,
+ );
+ }
+ }
+ await kv.set(KV.memories, group.targetMemoryId, updated);
+}
+
+async function unapplyActiveEdge(
+ kv: StateKV,
+ active: ActiveEdge,
+): Promise> {
+ const edgeState = await kv.get(
+ KV.state,
+ edgeStateKey(active.id),
+ );
+ if (!edgeState) {
+ await auditReaction(kv, active.id, [active.id], {
+ type: active.type,
+ action: "skip",
+ reason: "reaction_state_missing",
+ });
+ return { skipped: true, reason: "reaction_state_missing" };
+ }
+
+ return withKeyedLock(`graph-edge-reaction:${edgeState.targetMemoryId}`, async () => {
+ const group = await kv.get(
+ KV.state,
+ groupStateKey(edgeState.groupKey),
+ );
+ if (!group) {
+ await kv.delete(KV.state, edgeStateKey(active.id));
+ await auditReaction(kv, active.id, [active.id, edgeState.targetMemoryId], {
+ type: edgeState.type,
+ action: "skip",
+ reason: "reaction_group_missing",
+ });
+ return { skipped: true, reason: "reaction_group_missing" };
+ }
+
+ const remaining = readActiveIds(group).filter((id) => id !== active.id);
+ await kv.delete(KV.state, edgeStateKey(active.id));
+ if (remaining.length > 0) {
+ await kv.set(KV.state, groupStateKey(edgeState.groupKey), {
+ ...group,
+ activeEdgeIds: remaining,
+ });
+ } else {
+ await unapplyFinalGroup(kv, group);
+ await kv.delete(KV.state, groupStateKey(edgeState.groupKey));
+ }
+
+ await auditReaction(kv, active.id, [active.id, edgeState.targetMemoryId], {
+ type: edgeState.type,
+ action: "unapply",
+ remainingEdges: remaining.length,
+ });
+ return { unapplied: true, type: edgeState.type };
+ });
+}
+
+export async function handleGraphEdgeReactionEvent(
+ kv: StateKV,
+ rawPayload: unknown,
+): Promise> {
+ if (!isRecord(rawPayload)) {
+ await auditReaction(kv, "unknown", ["unknown"], {
+ action: "skip",
+ reason: "invalid_payload",
+ });
+ return { skipped: true, reason: "invalid_payload" };
+ }
+
+ const oldActive = activeEdgeFrom(readPayloadValue(rawPayload, "old_value"));
+ const newActive = activeEdgeFrom(readPayloadValue(rawPayload, "new_value"));
+ const edgeId =
+ newActive?.id ??
+ oldActive?.id ??
+ (typeof rawPayload.key === "string" ? rawPayload.key : "unknown");
+
+ if (isSameActiveEdge(oldActive, newActive)) {
+ await auditReaction(kv, edgeId, [edgeId], {
+ type: newActive?.type,
+ action: "skip",
+ reason: "unchanged_active_edge",
+ });
+ return { skipped: true, reason: "unchanged_active_edge" };
+ }
+
+ if (!oldActive && !newActive) {
+ await auditReaction(kv, edgeId, [edgeId], {
+ action: "skip",
+ reason: "no_active_lifecycle_edge",
+ });
+ return { skipped: true, reason: "no_active_lifecycle_edge" };
+ }
+
+ const result: Record = {};
+ if (oldActive) {
+ result.unapply = await unapplyActiveEdge(kv, oldActive);
+ }
+ if (newActive) {
+ result.apply = await applyActiveEdge(kv, newActive);
+ }
+
+ if (result.apply && !result.unapply) return result.apply as Record;
+ if (result.unapply && !result.apply) return result.unapply as Record;
+ return result;
+}
diff --git a/src/functions/graph-type-guards.ts b/src/functions/graph-type-guards.ts
index 7dce3428..ae518d77 100644
--- a/src/functions/graph-type-guards.ts
+++ b/src/functions/graph-type-guards.ts
@@ -33,6 +33,9 @@ const GRAPH_EDGE_TYPES = [
"avoids",
"located_in",
"succeeded_by",
+ "supersedes",
+ "contradicts",
+ "extends",
] as const satisfies readonly GraphEdgeType[];
export function isGraphNodeType(value: unknown): value is GraphNodeType {
diff --git a/src/functions/graph.ts b/src/functions/graph.ts
index 1e2a0c5f..8f084816 100644
--- a/src/functions/graph.ts
+++ b/src/functions/graph.ts
@@ -434,6 +434,55 @@ function mergeEdge(
};
}
+const LIFECYCLE_GRAPH_EDGE_TYPES = new Set([
+ "supersedes",
+ "contradicts",
+ "extends",
+]);
+
+function isLifecycleGraphEdge(edge: GraphEdge): boolean {
+ return LIFECYCLE_GRAPH_EDGE_TYPES.has(edge.type);
+}
+
+function hasLifecyclePath(
+ edges: GraphEdge[],
+ startNodeId: string,
+ targetNodeId: string,
+ excludedEdgeId: string,
+): boolean {
+ const visited = new Set();
+ const queue = [startNodeId];
+
+ while (queue.length > 0) {
+ const nodeId = queue.shift()!;
+ if (nodeId === targetNodeId) return true;
+ if (visited.has(nodeId)) continue;
+ visited.add(nodeId);
+
+ for (const edge of edges) {
+ if (edge.id === excludedEdgeId || !isLifecycleGraphEdge(edge)) continue;
+ if (edge.sourceNodeId !== nodeId) continue;
+ if (!visited.has(edge.targetNodeId)) queue.push(edge.targetNodeId);
+ }
+ }
+
+ return false;
+}
+
+function rejectLifecycleCycleEdges(edges: GraphEdge[]): GraphEdge[] {
+ const cycleEdgeIds = new Set();
+ for (const edge of edges) {
+ if (!isLifecycleGraphEdge(edge)) continue;
+ if (
+ edge.sourceNodeId === edge.targetNodeId ||
+ hasLifecyclePath(edges, edge.targetNodeId, edge.sourceNodeId, edge.id)
+ ) {
+ cycleEdgeIds.add(edge.id);
+ }
+ }
+ return edges.filter((edge) => !cycleEdgeIds.has(edge.id));
+}
+
function resolvePagination(
rawLimit: number | undefined,
rawOffset: number | undefined,
@@ -590,7 +639,7 @@ function parseGraphXml(
});
}
- return { nodes, edges };
+ return { nodes, edges: rejectLifecycleCycleEdges(edges) };
}
export function registerGraphFunction(
diff --git a/src/prompts/graph-extraction.ts b/src/prompts/graph-extraction.ts
index 4f1049c1..c875d523 100644
--- a/src/prompts/graph-extraction.ts
+++ b/src/prompts/graph-extraction.ts
@@ -7,12 +7,14 @@ Output format (XML):
-
+
Rules:
- Extract concrete entities only (real file paths, function names, library names)
- Use the most specific type available
+- Use supersedes, contradicts, and extends only for explicit memory lifecycle claims
+- When an entity refers to a known memory id, include that id
- Weight relationships by how strong/direct the connection is
- If no entities found, output empty tags`;
diff --git a/src/triggers/events.ts b/src/triggers/events.ts
index 412831a0..f22ae815 100644
--- a/src/triggers/events.ts
+++ b/src/triggers/events.ts
@@ -6,6 +6,7 @@ import { isReflectEnabled } from "../functions/slots.js";
import { normalizeSessionMetadata } from "../functions/session-metadata.js";
import { isGraphExtractionEnabled } from "../config.js";
import { logger } from "../logger.js";
+import { handleGraphEdgeReactionEvent } from "../functions/graph-edge-reactions.js";
export function registerEventTriggers(sdk: ISdk, kv: StateKV): void {
sdk.registerFunction(
@@ -167,4 +168,14 @@ export function registerEventTriggers(sdk: ISdk, kv: StateKV): void {
function_id: "event::session::observation-count-changed",
config: { scope: KV.sessions },
});
+
+ sdk.registerFunction(
+ "event::graph::edge::reaction",
+ async (payload: unknown) => handleGraphEdgeReactionEvent(kv, payload),
+ );
+ sdk.registerTrigger({
+ type: "state",
+ function_id: "event::graph::edge::reaction",
+ config: { scope: KV.graphEdges },
+ });
}
diff --git a/src/types.ts b/src/types.ts
index 6d818540..db15f5f5 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -541,7 +541,10 @@ export type GraphEdgeType =
| "rejected"
| "avoids"
| "located_in"
- | "succeeded_by";
+ | "succeeded_by"
+ | "supersedes"
+ | "contradicts"
+ | "extends";
export interface GraphEdge {
id: string;
diff --git a/test/events-boundary.test.ts b/test/events-boundary.test.ts
index 0dab91a4..8744d717 100644
--- a/test/events-boundary.test.ts
+++ b/test/events-boundary.test.ts
@@ -105,10 +105,11 @@ describe("event trigger boundaries", () => {
"event::session::stopped",
"event::session::ended",
"event::session::observation-count-changed",
+ "event::graph::edge::reaction",
]);
expect(sdk.triggers.at(-1)).toMatchObject({
type: "state",
- config: { scope: KV.sessions },
+ config: { scope: KV.graphEdges },
});
});
diff --git a/test/graph-edge-reactions.test.ts b/test/graph-edge-reactions.test.ts
new file mode 100644
index 00000000..4e758b50
--- /dev/null
+++ b/test/graph-edge-reactions.test.ts
@@ -0,0 +1,456 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("iii-sdk", () => ({
+ TriggerAction: {
+ Void: () => ({ type: "void" }),
+ },
+}));
+
+vi.mock("../src/functions/slots.js", () => ({
+ isReflectEnabled: vi.fn(() => false),
+}));
+
+vi.mock("../src/config.js", () => ({
+ isGraphExtractionEnabled: vi.fn(() => false),
+}));
+
+vi.mock("../src/logger.js", () => ({
+ logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
+}));
+
+import { registerEventTriggers } from "../src/triggers/events.js";
+import { KV } from "../src/state/schema.js";
+import type { GraphEdge, GraphNode, Memory } from "../src/types.js";
+
+type RegisteredHandler = (payload: unknown) => Promise;
+
+type MockKVOptions = {
+ cloneValues?: boolean;
+ failOnGraphEdgeList?: boolean;
+};
+
+function cloneValue(value: T): T {
+ if (value === null || value === undefined) return value;
+ return structuredClone(value);
+}
+
+function mockKV(options: MockKVOptions = {}) {
+ const store = new Map>();
+ return {
+ get: vi.fn(async (scope: string, key: string): Promise => {
+ const value = (store.get(scope)?.get(key) as T) ?? null;
+ return options.cloneValues ? cloneValue(value) : value;
+ }),
+ set: vi.fn(async (scope: string, key: string, data: T): Promise => {
+ if (!store.has(scope)) store.set(scope, new Map());
+ store.get(scope)!.set(key, options.cloneValues ? cloneValue(data) : data);
+ return data;
+ }),
+ update: vi.fn(async () => ({})),
+ delete: vi.fn(async (scope: string, key: string): Promise => {
+ store.get(scope)?.delete(key);
+ }),
+ list: vi.fn(async (scope: string): Promise => {
+ if (scope === KV.graphEdges && options.failOnGraphEdgeList) {
+ throw new Error("reaction handler must not list graph edges");
+ }
+ const entries = store.get(scope);
+ const values = entries ? (Array.from(entries.values()) as T[]) : [];
+ return options.cloneValues ? cloneValue(values) : values;
+ }),
+ seed: (scope: string, key: string, value: unknown) => {
+ if (!store.has(scope)) store.set(scope, new Map());
+ store.get(scope)!.set(key, options.cloneValues ? cloneValue(value) : value);
+ },
+ };
+}
+
+function mockSdk() {
+ const functions = new Map();
+ return {
+ registerFunction: (id: string, handler: RegisteredHandler) => {
+ functions.set(id, handler);
+ },
+ registerTrigger: vi.fn(),
+ trigger: vi.fn(async (input: { function_id: string; payload: unknown }) => {
+ if (input.function_id === "mem::context") return { context: "" };
+ if (input.function_id === "mem::summarize") return {};
+ if (input.function_id === "stream::send") return {};
+ const handler = functions.get(input.function_id);
+ if (!handler) throw new Error(`No function: ${input.function_id}`);
+ return handler(input.payload);
+ }),
+ getFunction: (id: string) => functions.get(id),
+ };
+}
+
+function makeMemory(overrides: Partial & { id: string }): Memory {
+ return {
+ id: overrides.id,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ type: "fact",
+ title: "Memory",
+ content: "Memory content",
+ concepts: [],
+ files: [],
+ sessionIds: [],
+ strength: 0.8,
+ version: 1,
+ isLatest: true,
+ ...overrides,
+ };
+}
+
+function makeNode(overrides: Partial & { id: string; name?: string }): GraphNode {
+ return {
+ id: overrides.id,
+ type: "concept",
+ name: overrides.name ?? overrides.id,
+ properties: {},
+ sourceObservationIds: [],
+ createdAt: "2026-01-01T00:00:00.000Z",
+ ...overrides,
+ };
+}
+
+function makeEdge(overrides: Partial & { id: string; type: string; sourceNodeId: string; targetNodeId: string }): GraphEdge {
+ return {
+ id: overrides.id,
+ type: overrides.type as GraphEdge["type"],
+ sourceNodeId: overrides.sourceNodeId,
+ targetNodeId: overrides.targetNodeId,
+ weight: overrides.weight ?? 1,
+ sourceObservationIds: [],
+ createdAt: "2026-01-01T00:00:00.000Z",
+ ...overrides,
+ };
+}
+
+describe("graph edge reactions", () => {
+ let sdk: ReturnType;
+ let kv: ReturnType;
+ let handler: RegisteredHandler;
+
+ beforeEach(() => {
+ sdk = mockSdk();
+ kv = mockKV({ failOnGraphEdgeList: true });
+ registerEventTriggers(sdk as never, kv as never);
+ handler = sdk.getFunction("event::graph::edge::reaction")!;
+ });
+
+ it("supersedes marks the target memory not latest and decays strength", async () => {
+ kv.seed(KV.memories, "mem_source", makeMemory({ id: "mem_source", strength: 0.9 }));
+ kv.seed(KV.memories, "mem_target", makeMemory({ id: "mem_target", strength: 0.8 }));
+ const edge = makeEdge({
+ id: "edge_sup",
+ type: "supersedes",
+ sourceNodeId: "mem_source",
+ targetNodeId: "mem_target",
+ weight: 1,
+ });
+
+ await expect(handler({ key: edge.id, event_type: "update", new_value: edge })).resolves.toMatchObject({
+ applied: true,
+ type: "supersedes",
+ });
+
+ const target = await kv.get(KV.memories, "mem_target");
+ expect(target?.isLatest).toBe(false);
+ expect(target?.strength).toBeCloseTo(0.4, 5);
+ });
+
+ it("contradicts decays target strength and numeric confidence with a floor", async () => {
+ kv.seed(KV.memories, "mem_source", { ...makeMemory({ id: "mem_source" }), confidence: 0.9 });
+ kv.seed(KV.memories, "mem_target", { ...makeMemory({ id: "mem_target", strength: 0.2 }), confidence: 0.2 });
+ const edge = makeEdge({
+ id: "edge_con",
+ type: "contradicts",
+ sourceNodeId: "mem_source",
+ targetNodeId: "mem_target",
+ weight: 0.1,
+ });
+
+ await handler({ key: edge.id, event_type: "update", new_value: edge });
+
+ const target = await kv.get(KV.memories, "mem_target");
+ expect(target?.strength).toBe(0.05);
+ expect(target?.confidence).toBe(0.05);
+ });
+
+ it("extends adds graph-node resolved source memory to target relatedIds once", async () => {
+ kv.seed(KV.memories, "mem_source", makeMemory({ id: "mem_source" }));
+ kv.seed(KV.memories, "mem_target", makeMemory({ id: "mem_target" }));
+ kv.seed(KV.graphNodes, "node_source", makeNode({
+ id: "node_source",
+ properties: { memoryId: "mem_source" },
+ }));
+ kv.seed(KV.graphNodes, "node_target", makeNode({
+ id: "node_target",
+ properties: { memoryId: "mem_target" },
+ }));
+ const edge = makeEdge({
+ id: "edge_ext",
+ type: "extends",
+ sourceNodeId: "node_source",
+ targetNodeId: "node_target",
+ weight: 1,
+ });
+
+ await handler({ key: edge.id, event_type: "update", new_value: edge });
+ await handler({ key: edge.id, event_type: "update", new_value: edge });
+
+ const target = await kv.get(KV.memories, "mem_target");
+ expect(target?.relatedIds).toEqual(["mem_source"]);
+ });
+
+ it("resolves graph node endpoint names that exactly match memory ids", async () => {
+ kv.seed(KV.memories, "mem_source", makeMemory({ id: "mem_source" }));
+ kv.seed(KV.memories, "mem_target", makeMemory({ id: "mem_target" }));
+ kv.seed(KV.graphNodes, "node_source", makeNode({ id: "node_source", name: "mem_source" }));
+ kv.seed(KV.graphNodes, "node_target", makeNode({ id: "node_target", name: "mem_target" }));
+ const edge = makeEdge({
+ id: "edge_name",
+ type: "extends",
+ sourceNodeId: "node_source",
+ targetNodeId: "node_target",
+ });
+
+ await handler({ key: edge.id, event_type: "update", new_value: edge });
+
+ const target = await kv.get(KV.memories, "mem_target");
+ expect(target?.relatedIds).toEqual(["mem_source"]);
+ });
+
+ it("skips graph node endpoints with conflicting memoryId and exact-name candidates", async () => {
+ kv.seed(KV.memories, "mem_property", makeMemory({ id: "mem_property" }));
+ kv.seed(KV.memories, "mem_name", makeMemory({ id: "mem_name" }));
+ kv.seed(KV.memories, "mem_target", makeMemory({ id: "mem_target" }));
+ kv.seed(KV.graphNodes, "node_source", makeNode({
+ id: "node_source",
+ name: "mem_name",
+ properties: { memoryId: "mem_property" },
+ }));
+ const edge = makeEdge({
+ id: "edge_ambiguous",
+ type: "extends",
+ sourceNodeId: "node_source",
+ targetNodeId: "mem_target",
+ });
+
+ await expect(handler({ key: edge.id, event_type: "update", new_value: edge })).resolves.toMatchObject({
+ skipped: true,
+ reason: "ambiguous_source_memory",
+ });
+
+ const target = await kv.get(KV.memories, "mem_target");
+ expect(target?.relatedIds).toBeUndefined();
+ });
+
+ it("ignores sourceObservationIds provenance when resolving graph-node endpoints", async () => {
+ kv.seed(KV.memories, "mem_source", makeMemory({ id: "mem_source" }));
+ kv.seed(KV.memories, "mem_target", makeMemory({ id: "mem_target" }));
+ kv.seed(KV.graphNodes, "node_source", makeNode({ id: "node_source", sourceObservationIds: ["mem_source"] }));
+ kv.seed(KV.graphNodes, "node_target", makeNode({ id: "node_target", sourceObservationIds: ["mem_target"] }));
+ const edge = makeEdge({
+ id: "edge_prov",
+ type: "extends",
+ sourceNodeId: "node_source",
+ targetNodeId: "node_target",
+ });
+
+ await expect(handler({ key: edge.id, event_type: "update", new_value: edge })).resolves.toMatchObject({
+ skipped: true,
+ });
+
+ const target = await kv.get(KV.memories, "mem_target");
+ expect(target?.relatedIds).toBeUndefined();
+ });
+
+ it("unapplies only after the final same-target supersedes edge is inactive", async () => {
+ kv.seed(KV.memories, "mem_source_a", makeMemory({ id: "mem_source_a" }));
+ kv.seed(KV.memories, "mem_source_b", makeMemory({ id: "mem_source_b" }));
+ kv.seed(KV.memories, "mem_target", makeMemory({ id: "mem_target", strength: 0.8 }));
+ const first = makeEdge({ id: "edge_a", type: "supersedes", sourceNodeId: "mem_source_a", targetNodeId: "mem_target" });
+ const second = makeEdge({ id: "edge_b", type: "supersedes", sourceNodeId: "mem_source_b", targetNodeId: "mem_target" });
+
+ await handler({ key: first.id, event_type: "update", new_value: first });
+ await handler({ key: second.id, event_type: "update", new_value: second });
+ await handler({ key: first.id, event_type: "update", old_value: first, new_value: { ...first, isLatest: false } });
+
+ let target = await kv.get(KV.memories, "mem_target");
+ expect(target?.isLatest).toBe(false);
+ expect(target?.strength).toBeCloseTo(0.4, 5);
+
+ await handler({ key: second.id, event_type: "delete", old_value: second });
+
+ target = await kv.get(KV.memories, "mem_target");
+ expect(target?.isLatest).toBe(true);
+ expect(target?.strength).toBeCloseTo(0.8, 5);
+ });
+
+ it("removes reaction-owned relatedIds only after the final extends edge is inactive", async () => {
+ kv.seed(KV.memories, "mem_source", makeMemory({ id: "mem_source" }));
+ kv.seed(KV.memories, "mem_target", makeMemory({ id: "mem_target" }));
+ const first = makeEdge({ id: "edge_1", type: "extends", sourceNodeId: "mem_source", targetNodeId: "mem_target" });
+ const second = makeEdge({ id: "edge_2", type: "extends", sourceNodeId: "mem_source", targetNodeId: "mem_target" });
+
+ await handler({ key: first.id, event_type: "update", new_value: first });
+ await handler({ key: second.id, event_type: "update", new_value: second });
+ await handler({ key: first.id, event_type: "delete", old_value: first });
+
+ let target = await kv.get(KV.memories, "mem_target");
+ expect(target?.relatedIds).toEqual(["mem_source"]);
+
+ await handler({ key: second.id, event_type: "delete", old_value: second });
+
+ target = await kv.get(KV.memories, "mem_target");
+ expect(target?.relatedIds).toEqual([]);
+ });
+
+ it("keeps pre-existing relatedIds after extends unapply", async () => {
+ kv.seed(KV.memories, "mem_source", makeMemory({ id: "mem_source" }));
+ kv.seed(KV.memories, "mem_target", makeMemory({ id: "mem_target", relatedIds: ["mem_source"] }));
+ const edge = makeEdge({ id: "edge_pre", type: "extends", sourceNodeId: "mem_source", targetNodeId: "mem_target" });
+
+ await handler({ key: edge.id, event_type: "update", new_value: edge });
+ await handler({ key: edge.id, event_type: "delete", old_value: edge });
+
+ const target = await kv.get(KV.memories, "mem_target");
+ expect(target?.relatedIds).toEqual(["mem_source"]);
+ });
+
+ it("keeps externally preserved relatedIds when final extends edge is removed", async () => {
+ kv.seed(KV.memories, "mem_source", makeMemory({ id: "mem_source" }));
+ kv.seed(KV.memories, "mem_target", makeMemory({ id: "mem_target" }));
+ const edge = makeEdge({ id: "edge_external", type: "extends", sourceNodeId: "mem_source", targetNodeId: "mem_target" });
+
+ await handler({ key: edge.id, event_type: "update", new_value: edge });
+ const target = await kv.get(KV.memories, "mem_target");
+ kv.seed(KV.memories, "mem_target", {
+ ...target!,
+ relatedIds: ["mem_source"],
+ updatedAt: "2026-01-01T00:00:01.000Z",
+ });
+ await handler({ key: edge.id, event_type: "delete", old_value: edge });
+
+ const after = await kv.get(KV.memories, "mem_target");
+ expect(after?.relatedIds).toEqual(["mem_source"]);
+ });
+
+ it("treats identical old and new active edge updates as no-ops", async () => {
+ kv.seed(KV.memories, "mem_source", makeMemory({ id: "mem_source" }));
+ kv.seed(KV.memories, "mem_target", makeMemory({ id: "mem_target" }));
+ const edge = makeEdge({ id: "edge_noop", type: "extends", sourceNodeId: "mem_source", targetNodeId: "mem_target" });
+
+ await handler({ key: edge.id, event_type: "update", new_value: edge });
+ await expect(handler({ key: edge.id, event_type: "update", old_value: edge, new_value: edge })).resolves.toMatchObject({
+ skipped: true,
+ reason: "unchanged_active_edge",
+ });
+ });
+
+ it("unapplies when an active edge is updated to stale", async () => {
+ kv.seed(KV.memories, "mem_source", makeMemory({ id: "mem_source" }));
+ kv.seed(KV.memories, "mem_target", makeMemory({ id: "mem_target", strength: 0.8 }));
+ const edge = makeEdge({ id: "edge_stale", type: "supersedes", sourceNodeId: "mem_source", targetNodeId: "mem_target" });
+
+ await handler({ key: edge.id, event_type: "update", new_value: edge });
+ await handler({ key: edge.id, event_type: "update", old_value: edge, new_value: { ...edge, stale: true } });
+
+ const target = await kv.get(KV.memories, "mem_target");
+ expect(target?.isLatest).toBe(true);
+ expect(target?.strength).toBeCloseTo(0.8, 5);
+ });
+
+ it("unapplies the old tuple and applies the new tuple when endpoints change", async () => {
+ kv.seed(KV.memories, "mem_source", makeMemory({ id: "mem_source" }));
+ kv.seed(KV.memories, "mem_target_a", makeMemory({ id: "mem_target_a" }));
+ kv.seed(KV.memories, "mem_target_b", makeMemory({ id: "mem_target_b" }));
+ const oldEdge = makeEdge({ id: "edge_move", type: "extends", sourceNodeId: "mem_source", targetNodeId: "mem_target_a" });
+ const newEdge = makeEdge({ ...oldEdge, targetNodeId: "mem_target_b" });
+
+ await handler({ key: oldEdge.id, event_type: "update", new_value: oldEdge });
+ await handler({ key: oldEdge.id, event_type: "update", old_value: oldEdge, new_value: newEdge });
+
+ const oldTarget = await kv.get(KV.memories, "mem_target_a");
+ const newTarget = await kv.get(KV.memories, "mem_target_b");
+ expect(oldTarget?.relatedIds).toEqual([]);
+ expect(newTarget?.relatedIds).toEqual(["mem_source"]);
+ });
+
+ it("skips directed lifecycle cycles without treating unrelated incident edges as cycles", async () => {
+ kv.seed(KV.memories, "mem_a", makeMemory({ id: "mem_a" }));
+ kv.seed(KV.memories, "mem_b", makeMemory({ id: "mem_b" }));
+ kv.seed(KV.memories, "mem_c", makeMemory({ id: "mem_c" }));
+ kv.seed(KV.graphAdjacency, "mem_b", ["edge_unrelated", "edge_reverse"]);
+ kv.seed(KV.graphEdges, "edge_unrelated", makeEdge({
+ id: "edge_unrelated",
+ type: "related_to",
+ sourceNodeId: "mem_b",
+ targetNodeId: "mem_a",
+ }));
+ kv.seed(KV.graphEdges, "edge_reverse", makeEdge({
+ id: "edge_reverse",
+ type: "supersedes",
+ sourceNodeId: "mem_b",
+ targetNodeId: "mem_c",
+ }));
+ kv.seed(KV.graphAdjacency, "mem_c", ["edge_back"]);
+ kv.seed(KV.graphEdges, "edge_back", makeEdge({
+ id: "edge_back",
+ type: "extends",
+ sourceNodeId: "mem_c",
+ targetNodeId: "mem_a",
+ }));
+ const cyclic = makeEdge({
+ id: "edge_cycle",
+ type: "supersedes",
+ sourceNodeId: "mem_a",
+ targetNodeId: "mem_b",
+ });
+
+ await expect(handler({ key: cyclic.id, event_type: "update", new_value: cyclic })).resolves.toMatchObject({
+ skipped: true,
+ reason: "cycle_detected",
+ });
+
+ const target = await kv.get(KV.memories, "mem_b");
+ expect(target?.isLatest).toBe(true);
+ });
+
+ it("serializes concurrent same-target reactions without losing bookkeeping", async () => {
+ kv = mockKV({ cloneValues: true, failOnGraphEdgeList: true });
+ registerEventTriggers(sdk as never, kv as never);
+ handler = sdk.getFunction("event::graph::edge::reaction")!;
+ kv.seed(KV.memories, "mem_source_a", makeMemory({ id: "mem_source_a" }));
+ kv.seed(KV.memories, "mem_source_b", makeMemory({ id: "mem_source_b" }));
+ kv.seed(KV.memories, "mem_target", makeMemory({ id: "mem_target" }));
+ const first = makeEdge({ id: "edge_conc_a", type: "extends", sourceNodeId: "mem_source_a", targetNodeId: "mem_target" });
+ const second = makeEdge({ id: "edge_conc_b", type: "extends", sourceNodeId: "mem_source_b", targetNodeId: "mem_target" });
+
+ await Promise.all([
+ handler({ key: first.id, event_type: "update", new_value: first }),
+ handler({ key: second.id, event_type: "update", new_value: second }),
+ ]);
+
+ const target = await kv.get(KV.memories, "mem_target");
+ expect(new Set(target?.relatedIds)).toEqual(new Set(["mem_source_a", "mem_source_b"]));
+ });
+
+ it("writes audit entries for applied and skipped reactions", async () => {
+ kv.seed(KV.memories, "mem_source", makeMemory({ id: "mem_source" }));
+ kv.seed(KV.memories, "mem_target", makeMemory({ id: "mem_target" }));
+ const applied = makeEdge({ id: "edge_audit_ok", type: "extends", sourceNodeId: "mem_source", targetNodeId: "mem_target" });
+ const skipped = makeEdge({ id: "edge_audit_skip", type: "extends", sourceNodeId: "missing", targetNodeId: "mem_target" });
+
+ await handler({ key: applied.id, event_type: "update", new_value: applied });
+ await handler({ key: skipped.id, event_type: "update", new_value: skipped });
+
+ const audits = await kv.list<{ operation: string; functionId: string; targetIds: string[] }>(KV.audit);
+ expect(audits.length).toBeGreaterThanOrEqual(2);
+ expect(audits.every((entry) => entry.operation === "relation_update")).toBe(true);
+ expect(audits.every((entry) => entry.functionId === "event::graph::edge::reaction")).toBe(true);
+ expect(audits.some((entry) => entry.targetIds.includes(skipped.id))).toBe(true);
+ });
+});
diff --git a/test/graph.test.ts b/test/graph.test.ts
index 08dad698..e644f51d 100644
--- a/test/graph.test.ts
+++ b/test/graph.test.ts
@@ -203,6 +203,63 @@ describe("Graph Functions", () => {
expect(edges[0].type).toBe("uses");
});
+ it("graph-extract accepts explicit memory lifecycle edge types", async () => {
+ mockProvider.compress.mockResolvedValueOnce(`
+mem_source
+mem_target
+
+
+
+
+
+`);
+
+ const result = (await sdk.trigger("mem::graph-extract", {
+ observations: [testObs],
+ })) as { success: boolean; nodesAdded: number; edgesAdded: number };
+
+ expect(result.success).toBe(true);
+ expect(result.nodesAdded).toBe(2);
+ expect(result.edgesAdded).toBe(3);
+
+ const nodes = await kv.list("mem:graph:nodes");
+ expect(nodes.find((n) => n.name === "mem_source")?.properties.memoryId).toBe("mem_source");
+ expect(nodes.find((n) => n.name === "mem_target")?.properties.memoryId).toBe("mem_target");
+
+ const edges = await kv.list("mem:graph:edges");
+ expect(edges.map((e) => e.type).sort()).toEqual([
+ "contradicts",
+ "extends",
+ "supersedes",
+ ]);
+ });
+
+ it("graph-extract rejects every lifecycle edge participating in an in-batch directed cycle", async () => {
+ mockProvider.compress.mockResolvedValueOnce(`
+mem_a
+mem_b
+mem_c
+mem_d
+mem_e
+
+
+
+
+
+
+`);
+
+ const result = (await sdk.trigger("mem::graph-extract", {
+ observations: [testObs],
+ })) as { success: boolean; edgesAdded: number };
+
+ expect(result.success).toBe(true);
+ expect(result.edgesAdded).toBe(1);
+
+ const edges = await kv.list("mem:graph:edges");
+ expect(edges.map((e) => e.type)).toEqual(["extends"]);
+ });
+
it("graph-query with search returns matching nodes", async () => {
await sdk.trigger("mem::graph-extract", { observations: [testObs] });