From 289a9f5c47680df803608c33df406600ec04875e Mon Sep 17 00:00:00 2001 From: Willi Budzinski Date: Fri, 19 Jun 2026 19:24:21 +0200 Subject: [PATCH] feat(graph): react to lifecycle edge state changes --- .../plan.md | 446 +++++++++++++ .../todo.md | 139 ++++ src/functions/graph-edge-reactions.ts | 609 ++++++++++++++++++ src/functions/graph-type-guards.ts | 3 + src/functions/graph.ts | 51 +- src/prompts/graph-extraction.ts | 4 +- src/triggers/events.ts | 11 + src/types.ts | 5 +- test/events-boundary.test.ts | 3 +- test/graph-edge-reactions.test.ts | 456 +++++++++++++ test/graph.test.ts | 57 ++ 11 files changed, 1780 insertions(+), 4 deletions(-) create mode 100644 docs/todos/2026-06-19-issue-312-graph-auto-stale-edges/plan.md create mode 100644 docs/todos/2026-06-19-issue-312-graph-auto-stale-edges/todo.md create mode 100644 src/functions/graph-edge-reactions.ts create mode 100644 test/graph-edge-reactions.test.ts 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 000000000..0af294ffc --- /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 000000000..e8a28b285 --- /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 000000000..1f52b2b2e --- /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 7dce34282..ae518d77a 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 b6aab42dd..a84b2dd8a 100644 --- a/src/functions/graph.ts +++ b/src/functions/graph.ts @@ -433,6 +433,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, @@ -589,7 +638,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 4f1049c1a..c875d523c 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 412831a04..f22ae815c 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 5054ecbfc..0d5f208a7 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 0dab91a42..8744d7174 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 000000000..4e758b50c --- /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 7f1b909b9..0dce8fd9e 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] });