From eb99422200402160db846c4be561aa4976814fca Mon Sep 17 00:00:00 2001 From: coodos Date: Wed, 23 Sep 2026 14:12:37 +0530 Subject: [PATCH 1/4] feat(evault): keep every MetaEnvelope write as an immutable version --- .../evault-core/src/core/db/db.service.ts | 373 +++++++++++++++++- .../add-metaenvelope-version-indexes.ts | 20 + infrastructure/evault-core/src/index.ts | 9 + 3 files changed, 384 insertions(+), 18 deletions(-) create mode 100644 infrastructure/evault-core/src/core/db/migrations/add-metaenvelope-version-indexes.ts diff --git a/infrastructure/evault-core/src/core/db/db.service.ts b/infrastructure/evault-core/src/core/db/db.service.ts index ceadca000..6304882b5 100644 --- a/infrastructure/evault-core/src/core/db/db.service.ts +++ b/infrastructure/evault-core/src/core/db/db.service.ts @@ -1,4 +1,4 @@ -import neo4j, { type Driver } from "neo4j-driver"; +import neo4j, { type Driver, type ManagedTransaction } from "neo4j-driver"; import { randomUUID } from "node:crypto"; import { W3IDBuilder } from "w3id"; import { timed } from "../utils/timing"; @@ -72,6 +72,75 @@ const CREATE_AWARENESS_OUTBOX = ` }) `; +export type MetaEnvelopeVersionOperation = "create" | "update" | "delete"; + +/** + * The full state of a MetaEnvelope at one point in time. `payload` is null for + * a delete, which records that the record was pruned rather than a new state. + */ +interface MetaEnvelopeSnapshot { + ontology: string; + acl: string[]; + aclBlock: string | null; + payload: Record | null; +} + +function metaEnvelopeVersionParams( + metaEnvelopeId: string, + eName: string, + operation: MetaEnvelopeVersionOperation, + snapshot: MetaEnvelopeSnapshot, + requestingPlatform: string | null, +): Record { + return { + versionMetaEnvelopeId: metaEnvelopeId, + versionEName: eName, + versionOperation: operation, + versionOntology: snapshot.ontology, + versionAcl: snapshot.acl ?? [], + versionAclBlock: snapshot.aclBlock ?? null, + versionPayloadJson: + snapshot.payload === null ? null : JSON.stringify(snapshot.payload), + versionRequestingPlatform: requestingPlatform, + versionCreatedAt: new Date().toISOString(), + versionNow: Date.now(), + }; +} + +// Every write to a MetaEnvelope appends an immutable snapshot of its resulting +// state. History lives on its own nodes keyed by (metaEnvelopeId, eName) and is +// never linked into the MetaEnvelope graph, so live reads are unaffected and +// the history outlives pruning and re-creation of the record. +const APPEND_METAENVELOPE_VERSION = ` + MERGE (h:MetaEnvelopeHistory { metaEnvelopeId: $versionMetaEnvelopeId, eName: $versionEName }) + ON CREATE SET h.createdAt = $versionNow + SET h.latestVersion = coalesce(h.latestVersion, 0) + 1, h.updatedAt = $versionNow + CREATE (:MetaEnvelopeVersion { + metaEnvelopeId: $versionMetaEnvelopeId, + eName: $versionEName, + version: h.latestVersion, + operation: $versionOperation, + ontology: $versionOntology, + acl: $versionAcl, + aclBlock: $versionAclBlock, + payloadJson: $versionPayloadJson, + requestingPlatform: $versionRequestingPlatform, + createdAt: $versionCreatedAt + }) +`; + +function payloadFromEnvelopeNodes(nodes: any[]): Record { + const payload: Record = {}; + for (const node of nodes) { + if (!node) continue; + payload[node.properties.ontology] = deserializeValue( + node.properties.value, + node.properties.valueType, + ); + } + return payload; +} + /** * Service for managing meta-envelopes and their associated envelopes in Neo4j. * Provides functionality for storing, retrieving, searching, and updating data @@ -116,6 +185,105 @@ export class DbService { return this.runQueryInternal(query, params); } + /** + * Takes the write lock on a record's history and returns its latest + * version (0 when none has been recorded yet). Every writer locks the + * history before touching the record, so snapshots are appended in the + * order the writes land. + */ + private async lockHistory( + tx: ManagedTransaction, + metaEnvelopeId: string, + eName: string, + ): Promise { + const result = await tx.run( + ` + MERGE (h:MetaEnvelopeHistory { metaEnvelopeId: $metaEnvelopeId, eName: $eName }) + ON CREATE SET h.createdAt = $now, h.latestVersion = 0 + SET h.updatedAt = $now + RETURN h.latestVersion AS latestVersion + `, + { metaEnvelopeId, eName, now: Date.now() }, + ); + const latest = result.records[0]?.get("latestVersion"); + return typeof latest?.toNumber === "function" + ? latest.toNumber() + : Number(latest ?? 0); + } + + /** + * Reads the live state of a record inside a transaction, or null when no + * live record exists for the id and eName. + */ + private async readLiveSnapshot( + tx: ManagedTransaction, + metaEnvelopeId: string, + eName: string, + ): Promise { + const result = await tx.run( + ` + MATCH (m:MetaEnvelope { id: $metaEnvelopeId, eName: $eName }) + OPTIONAL MATCH (m)-[:LINKS_TO]->(e:Envelope) + RETURN m.ontology AS ontology, m.acl AS acl, m.aclBlock AS aclBlock, collect(e) AS envelopes + `, + { metaEnvelopeId, eName }, + ); + const record = result.records[0]; + if (!record) return null; + return { + ontology: record.get("ontology"), + acl: record.get("acl") ?? [], + aclBlock: record.get("aclBlock") ?? null, + payload: payloadFromEnvelopeNodes(record.get("envelopes")), + }; + } + + private async appendVersion( + tx: ManagedTransaction, + metaEnvelopeId: string, + eName: string, + operation: MetaEnvelopeVersionOperation, + snapshot: MetaEnvelopeSnapshot, + requestingPlatform: string | null, + ): Promise { + await tx.run( + APPEND_METAENVELOPE_VERSION, + metaEnvelopeVersionParams( + metaEnvelopeId, + eName, + operation, + snapshot, + requestingPlatform, + ), + ); + } + + /** + * Locks a record's history and returns its live state before the caller + * changes it. A record written before versioning existed has no history, + * so its current state is recorded first as the baseline it started from. + */ + private async prepareVersionedWrite( + tx: ManagedTransaction, + metaEnvelopeId: string, + eName: string, + requestingPlatform: string | null, + ): Promise { + const latestVersion = await this.lockHistory(tx, metaEnvelopeId, eName); + const live = await this.readLiveSnapshot(tx, metaEnvelopeId, eName); + if (latestVersion === 0 && live) { + await this.appendVersion( + tx, + metaEnvelopeId, + eName, + "create", + live, + requestingPlatform, + ); + } + return live; + } + /** * Stores a new meta-envelope and its associated envelopes. * @param meta - The meta-envelope data (without ID) @@ -189,6 +357,23 @@ export class DbService { counter++; } + cypher.push("WITH m", APPEND_METAENVELOPE_VERSION); + Object.assign( + envelopeParams, + metaEnvelopeVersionParams( + w3id.id, + eName, + "create", + { + ontology: meta.ontology, + acl, + aclBlock: envelopeParams.aclBlock ?? null, + payload: meta.payload, + }, + awareness?.requestingPlatform ?? null, + ), + ); + if (awareness && !awareness.skipAwareness) { cypher.push(CREATE_AWARENESS_OUTBOX); Object.assign( @@ -306,7 +491,66 @@ export class DbService { ); } - await this.runQueryInternal(cypher.join("\n"), envelopeParams); + const requestingPlatform = awareness?.requestingPlatform ?? null; + const session = this.driver.session(); + try { + await session.executeWrite(async (tx) => { + // The MERGE only matches on id, so a re-run against an existing + // record adds to it rather than creating it; version it as an + // update of that record's owner. + const existing = await tx.run( + "MATCH (m:MetaEnvelope { id: $metaId }) RETURN m.eName AS eName LIMIT 1", + { metaId }, + ); + const existed = existing.records.length > 0; + const historyEName: string = + existing.records[0]?.get("eName") ?? eName; + if (existed) { + await this.prepareVersionedWrite( + tx, + metaId, + historyEName, + requestingPlatform, + ); + } + + await tx.run(cypher.join("\n"), envelopeParams); + + if (!existed) { + await this.appendVersion( + tx, + metaId, + eName, + "create", + { + ontology: meta.ontology, + acl, + aclBlock: envelopeParams.aclBlock ?? null, + payload: meta.payload, + }, + requestingPlatform, + ); + return; + } + const live = await this.readLiveSnapshot( + tx, + metaId, + historyEName, + ); + if (live) { + await this.appendVersion( + tx, + metaId, + historyEName, + "update", + live, + requestingPlatform, + ); + } + }); + } finally { + await session.close(); + } return { metaEnvelope: { @@ -696,25 +940,59 @@ export class DbService { const session = this.driver.session(); try { await session.executeWrite(async (tx) => { + const owner = await tx.run( + ` + MATCH (m:MetaEnvelope { eName: $eName })-[:LINKS_TO]->(:Envelope { id: $envelopeId }) + RETURN m.id AS id LIMIT 1 + `, + { envelopeId, eName }, + ); + const metaEnvelopeId: string | undefined = + owner.records[0]?.get("id"); + if (!metaEnvelopeId) return; + const requestingPlatform = awareness?.requestingPlatform ?? null; + await this.prepareVersionedWrite( + tx, + metaEnvelopeId, + eName, + requestingPlatform, + ); + const result = await tx.run( ` - MATCH (m:MetaEnvelope { eName: $eName })-[:LINKS_TO]->(e:Envelope { id: $envelopeId }) + MATCH (m:MetaEnvelope { id: $metaEnvelopeId, eName: $eName })-[:LINKS_TO]->(e:Envelope { id: $envelopeId }) SET e.value = $newValue, e.valueType = $valueType WITH m MATCH (m)-[:LINKS_TO]->(allEnvelope:Envelope) - RETURN m.id AS id, m.ontology AS ontology, collect(allEnvelope) AS envelopes + RETURN m.id AS id, m.ontology AS ontology, m.acl AS acl, m.aclBlock AS aclBlock, collect(allEnvelope) AS envelopes `, - { envelopeId, newValue: storedValue, valueType, eName }, + { + metaEnvelopeId, + envelopeId, + newValue: storedValue, + valueType, + eName, + }, ); const record = result.records[0]; - if (!record || !awareness || awareness.skipAwareness) return; - const payload: Record = {}; - for (const node of record.get("envelopes")) { - payload[node.properties.ontology] = deserializeValue( - node.properties.value, - node.properties.valueType, - ); - } + if (!record) return; + const payload = payloadFromEnvelopeNodes( + record.get("envelopes"), + ); + await this.appendVersion( + tx, + metaEnvelopeId, + eName, + "update", + { + ontology: record.get("ontology"), + acl: record.get("acl") ?? [], + aclBlock: record.get("aclBlock") ?? null, + payload, + }, + requestingPlatform, + ); + if (!awareness || awareness.skipAwareness) return; await tx.run( `MATCH (m:MetaEnvelope { id: $awarenessPacketId, eName: $awarenessW3id }) ${CREATE_AWARENESS_OUTBOX}`, @@ -758,13 +1036,22 @@ export class DbService { } // The whole read-modify-write cycle runs inside a single Neo4j write - // transaction. The opening MERGE+SET acquires a write lock on the - // MetaEnvelope node, so concurrent updates to the same id serialize - // here — without this, request B's "delete stale envelopes" step - // could clobber fields that request A just wrote. + // transaction. Locking the record's history first, and then the + // opening MERGE+SET on the MetaEnvelope node, makes concurrent + // updates to the same id serialize here — without this, request + // B's "delete stale envelopes" step could clobber fields that + // request A just wrote, and versions could be recorded out of order. const session = this.driver.session(); + const requestingPlatform = awareness?.requestingPlatform ?? null; try { return await session.executeWrite(async (tx) => { + const previous = await this.prepareVersionedWrite( + tx, + id, + eName, + requestingPlatform, + ); + const findResult = await tx.run( ` MERGE (m:MetaEnvelope { id: $id, eName: $eName }) @@ -772,7 +1059,7 @@ export class DbService { ON MATCH SET m.ontology = $ontology, m.acl = $acl, m.aclBlock = coalesce($aclBlock, m.aclBlock) WITH m OPTIONAL MATCH (m)-[:LINKS_TO]->(e:Envelope) - RETURN collect(e) AS envelopes + RETURN m.aclBlock AS aclBlock, collect(e) AS envelopes `, { id, @@ -899,6 +1186,21 @@ export class DbService { mergedPayload[env.ontology] = env.value; } + await this.appendVersion( + tx, + id, + eName, + previous ? "update" : "create", + { + ontology: meta.ontology, + acl, + aclBlock: + findResult.records[0]?.get("aclBlock") ?? null, + payload: mergedPayload, + }, + requestingPlatform, + ); + if (awareness && !awareness.skipAwareness) { await tx.run( `MATCH (m:MetaEnvelope { id: $awarenessPacketId, eName: $awarenessW3id }) @@ -1161,6 +1463,41 @@ export class DbService { } } + // Copy the version history so it survives the move with its records + const historyResult = await this.runQueryInternal( + `MATCH (h:MetaEnvelopeHistory { eName: $eName }) RETURN properties(h) AS props`, + { eName }, + ); + const histories = historyResult.records.map((r) => r.get("props")); + if (histories.length > 0) { + await targetDbService.runQuery( + ` + UNWIND $rows AS row + MERGE (h:MetaEnvelopeHistory { metaEnvelopeId: row.metaEnvelopeId, eName: row.eName }) + SET h += row + `, + { rows: histories }, + ); + } + const versionResult = await this.runQueryInternal( + `MATCH (v:MetaEnvelopeVersion { eName: $eName }) RETURN properties(v) AS props`, + { eName }, + ); + const versions = versionResult.records.map((r) => r.get("props")); + if (versions.length > 0) { + await targetDbService.runQuery( + ` + UNWIND $rows AS row + MERGE (v:MetaEnvelopeVersion { metaEnvelopeId: row.metaEnvelopeId, eName: row.eName, version: row.version }) + SET v += row + `, + { rows: versions }, + ); + console.log( + `[MIGRATION] Copied ${versions.length} MetaEnvelope versions for eName: ${eName}`, + ); + } + // Copy User node with public keys if it exists try { const userResult = await this.runQueryInternal( diff --git a/infrastructure/evault-core/src/core/db/migrations/add-metaenvelope-version-indexes.ts b/infrastructure/evault-core/src/core/db/migrations/add-metaenvelope-version-indexes.ts new file mode 100644 index 000000000..dd1b031c3 --- /dev/null +++ b/infrastructure/evault-core/src/core/db/migrations/add-metaenvelope-version-indexes.ts @@ -0,0 +1,20 @@ +import type { Driver } from "neo4j-driver"; + +export async function createMetaEnvelopeVersionIndexes( + driver: Driver, +): Promise { + const session = driver.session(); + try { + await session.run( + "CREATE CONSTRAINT metaenvelope_history_key IF NOT EXISTS FOR (h:MetaEnvelopeHistory) REQUIRE (h.metaEnvelopeId, h.eName) IS UNIQUE", + ); + await session.run( + "CREATE INDEX metaenvelope_version_key IF NOT EXISTS FOR (v:MetaEnvelopeVersion) ON (v.metaEnvelopeId, v.eName, v.version)", + ); + await session.run( + "CREATE INDEX metaenvelope_version_ename IF NOT EXISTS FOR (v:MetaEnvelopeVersion) ON (v.eName)", + ); + } finally { + await session.close(); + } +} diff --git a/infrastructure/evault-core/src/index.ts b/infrastructure/evault-core/src/index.ts index 42424316c..6b74776fb 100644 --- a/infrastructure/evault-core/src/index.ts +++ b/infrastructure/evault-core/src/index.ts @@ -165,6 +165,15 @@ const initializeEVault = async ( console.warn("Failed to create awareness outbox indexes:", error); } + try { + const { createMetaEnvelopeVersionIndexes } = await import( + "./core/db/migrations/add-metaenvelope-version-indexes" + ); + await createMetaEnvelopeVersionIndexes(driver); + } catch (error) { + console.warn("Failed to create MetaEnvelope version indexes:", error); + } + // One-time backfill: create operation logs for existing metaenvelopes (platform inferred from ontology) try { const { backfillEnvelopeOperationLogs } = await import( From a045fff90c6d5e1308719de34372415f248646f7 Mon Sep 17 00:00:00 2001 From: coodos Date: Wed, 23 Sep 2026 18:47:09 +0530 Subject: [PATCH 2/4] feat(evault): prune MetaEnvelopes on delete instead of destroying them --- .../evault-core/src/core/db/db.service.ts | 73 +++++++++++++++---- 1 file changed, 57 insertions(+), 16 deletions(-) diff --git a/infrastructure/evault-core/src/core/db/db.service.ts b/infrastructure/evault-core/src/core/db/db.service.ts index 6304882b5..425779ca0 100644 --- a/infrastructure/evault-core/src/core/db/db.service.ts +++ b/infrastructure/evault-core/src/core/db/db.service.ts @@ -864,7 +864,10 @@ export class DbService { } /** - * Deletes a meta-envelope and all its associated envelopes. + * Deletes a meta-envelope by pruning it: nothing is destroyed. The record + * and its envelopes are relabelled PrunedMetaEnvelope / PrunedEnvelope, so + * every live read stops seeing them, and a delete version is appended to + * the record's history, which stays readable. * @param id - The ID of the meta-envelope to delete * @param eName - The eName identifier for multi-tenant isolation */ @@ -877,7 +880,12 @@ export class DbService { throw new Error("eName is required for deleting meta-envelopes"); } - const params: Record = { id, eName }; + const requestingPlatform = awareness?.requestingPlatform ?? null; + const params: Record = { + id, + eName, + prunedAt: Date.now(), + }; const outbox = awareness && !awareness.skipAwareness; if (outbox) { Object.assign( @@ -885,8 +893,20 @@ export class DbService { awarenessOutboxParams(id, "", eName, null, "delete", awareness), ); } - await this.runQueryInternal( - ` + + const session = this.driver.session(); + try { + await session.executeWrite(async (tx) => { + const live = await this.prepareVersionedWrite( + tx, + id, + eName, + requestingPlatform, + ); + if (!live) return; + + await tx.run( + ` MATCH (m:MetaEnvelope { id: $id, eName: $eName }) OPTIONAL MATCH (m)-[:LINKS_TO]->(e:Envelope) WITH m, collect(e) AS envelopes @@ -911,11 +931,27 @@ export class DbService { WITH m, envelopes` : "" } - FOREACH (node IN envelopes | DETACH DELETE node) - DETACH DELETE m + FOREACH (node IN envelopes | + REMOVE node:Envelope + SET node:PrunedEnvelope, node.prunedAt = $prunedAt) + REMOVE m:MetaEnvelope + SET m:PrunedMetaEnvelope, m.prunedAt = $prunedAt `, - params, - ); + params, + ); + + await this.appendVersion( + tx, + id, + eName, + "delete", + { ...live, payload: null }, + requestingPlatform, + ); + }); + } finally { + await session.close(); + } } /** @@ -1086,26 +1122,31 @@ export class DbService { })); // Deduplicate envelopes — if multiple Envelope nodes share the - // same ontology, keep the first and delete the rest. + // same ontology, keep the first and prune the rest. const seen = new Map(); - const dupsToDelete: string[] = []; + const dupsToPrune: string[] = []; for (const env of workingEnvelopes) { if (seen.has(env.ontology)) { - dupsToDelete.push(env.id); + dupsToPrune.push(env.id); } else { seen.set(env.ontology, env.id); } } - if (dupsToDelete.length > 0) { + if (dupsToPrune.length > 0) { console.warn( - `[eVault] Cleaning ${dupsToDelete.length} duplicate envelope(s) for MetaEnvelope ${id}`, + `[eVault] Pruning ${dupsToPrune.length} duplicate envelope(s) for MetaEnvelope ${id}`, ); await tx.run( - `MATCH (e:Envelope) WHERE e.id IN $ids DETACH DELETE e`, - { ids: dupsToDelete }, + ` + MATCH (:MetaEnvelope { id: $id, eName: $eName })-[:LINKS_TO]->(e:Envelope) + WHERE e.id IN $ids + REMOVE e:Envelope + SET e:PrunedEnvelope, e.prunedAt = $now + `, + { id, eName, ids: dupsToPrune, now: Date.now() }, ); workingEnvelopes = workingEnvelopes.filter( - (e) => !dupsToDelete.includes(e.id), + (e) => !dupsToPrune.includes(e.id), ); } From 4bb4c975d392b8047f5d3aa3d5e65839878728b8 Mon Sep 17 00:00:00 2001 From: coodos Date: Thu, 24 Sep 2026 11:26:52 +0530 Subject: [PATCH 3/4] feat(evault): expose MetaEnvelope version history over GraphQL --- docs/docs/Infrastructure/eVault.md | 42 +++++- .../evault-core/src/core/db/db.service.ts | 138 ++++++++++++++++++ .../evault-core/src/core/db/types.ts | 21 +++ .../src/core/protocol/graphql-server.ts | 20 +++ .../evault-core/src/core/protocol/typedefs.ts | 41 ++++++ .../src/core/protocol/vault-access-guard.ts | 28 +++- skills/w3ds/reference/evault.md | 16 ++ 7 files changed, 300 insertions(+), 6 deletions(-) diff --git a/docs/docs/Infrastructure/eVault.md b/docs/docs/Infrastructure/eVault.md index efec83d0f..23f00d975 100644 --- a/docs/docs/Infrastructure/eVault.md +++ b/docs/docs/Infrastructure/eVault.md @@ -87,6 +87,15 @@ In Neo4j, the structure looks like: (MetaEnvelope {id, ontology, acl, aclBlock}) -[:LINKS_TO]-> (Envelope {id, value, valueType}) ``` +This graph always holds the current state. History is kept alongside it, never linked into it: + +```cypher +(MetaEnvelopeHistory {metaEnvelopeId, eName, latestVersion}) +(MetaEnvelopeVersion {metaEnvelopeId, eName, version, operation, ontology, acl, aclBlock, payloadJson, requestingPlatform, createdAt}) +``` + +A removed record is relabelled `PrunedMetaEnvelope`, and its Envelopes `PrunedEnvelope`, so no read returns it while its data is kept. + This flat graph structure allows: - Efficient field-level updates - Flexible querying @@ -188,6 +197,37 @@ query { - `first` / `after`: Forward pagination - `last` / `before`: Backward pagination +#### metaEnvelopeHistory + +Retrieve every recorded version of a MetaEnvelope, newest first. Each create, update and remove appends an immutable version holding the full payload as it stood after that write; nothing is overwritten. History stays readable after `removeMetaEnvelope`, guarded by the access policy the record last carried. + +**Query**: +```graphql +query { + metaEnvelopeHistory(id: "global-id-123", first: 20) { + edges { + cursor + node { + version + operation + ontology + parsed + requestingPlatform + createdAt + } + } + pageInfo { + hasNextPage + endCursor + } + totalCount + } +} +``` + +- `operation` is `create`, `update` or `delete`. A `delete` version has `parsed: null`. +- Records written before versioning existed get a baseline `create` version from their state at their first later write. + ### Mutations #### createMetaEnvelope @@ -259,7 +299,7 @@ mutation { #### removeMetaEnvelope -Delete a MetaEnvelope and all its Envelopes. Returns a structured payload confirming deletion. +Remove a MetaEnvelope. Removal prunes rather than destroys: the record and its Envelopes disappear from every read, but their data is kept and the record's version history remains available through `metaEnvelopeHistory`. Returns a structured payload confirming the removal. **Mutation**: ```graphql diff --git a/infrastructure/evault-core/src/core/db/db.service.ts b/infrastructure/evault-core/src/core/db/db.service.ts index 425779ca0..1c88a5d38 100644 --- a/infrastructure/evault-core/src/core/db/db.service.ts +++ b/infrastructure/evault-core/src/core/db/db.service.ts @@ -17,6 +17,8 @@ import type { MetaEnvelopeFilterInput, MetaEnvelopeResult, MetaEnvelopeSearchInput, + MetaEnvelopeVersion, + MetaEnvelopeVersionConnection, PageInfo, SearchMetaEnvelopesResult, StoreMetaEnvelopeResult, @@ -1765,6 +1767,142 @@ export class DbService { return { logs, nextCursor, hasMore }; } + /** + * Returns the version history of a MetaEnvelope, newest first. History is + * kept for pruned records too, so this answers for ids that no live read + * returns any more. + * @param id - The ID of the meta-envelope + * @param eName - The eName identifier for multi-tenant isolation + */ + async getMetaEnvelopeVersions< + T extends Record = Record, + >( + id: string, + eName: string, + options: { first?: number; after?: string } = {}, + ): Promise> { + if (!eName) { + throw new Error( + "eName is required for reading meta-envelope history", + ); + } + + const limit = Math.min(Math.max(1, options.first ?? 20), 100); + let afterVersion: number | null = null; + if (options.after) { + afterVersion = Number( + Buffer.from(options.after, "base64").toString("utf-8"), + ); + if (!Number.isInteger(afterVersion)) { + throw new Error("Invalid cursor"); + } + } + + const session = this.driver.session(); + let countResult; + let result; + try { + countResult = await session.run( + ` + MATCH (v:MetaEnvelopeVersion { metaEnvelopeId: $id, eName: $eName }) + RETURN count(v) AS total + `, + { id, eName }, + ); + result = await session.run( + ` + MATCH (v:MetaEnvelopeVersion { metaEnvelopeId: $id, eName: $eName }) + WHERE $afterVersion IS NULL OR v.version < $afterVersion + WITH v + ORDER BY v.version DESC + LIMIT $limitPlusOne + RETURN v.version AS version, v.operation AS operation, v.ontology AS ontology, + v.payloadJson AS payloadJson, v.requestingPlatform AS requestingPlatform, + v.createdAt AS createdAt + `, + { + id, + eName, + afterVersion: + afterVersion === null ? null : neo4j.int(afterVersion), + limitPlusOne: neo4j.int(limit + 1), + }, + ); + } finally { + await session.close(); + } + + const total = countResult.records[0]?.get("total"); + const totalCount = + typeof total?.toNumber === "function" + ? total.toNumber() + : Number(total ?? 0); + + const hasMore = result.records.length > limit; + const edges = result.records.slice(0, limit).map((record) => { + const rawVersion = record.get("version"); + const version = + typeof rawVersion?.toNumber === "function" + ? rawVersion.toNumber() + : Number(rawVersion); + const payloadJson = record.get("payloadJson"); + const node: MetaEnvelopeVersion = { + metaEnvelopeId: id, + version, + operation: record.get("operation"), + ontology: record.get("ontology"), + parsed: payloadJson == null ? null : JSON.parse(payloadJson), + requestingPlatform: record.get("requestingPlatform") ?? null, + createdAt: record.get("createdAt"), + }; + return { + cursor: Buffer.from(String(version)).toString("base64"), + node, + }; + }); + + return { + edges, + pageInfo: { + hasNextPage: hasMore, + hasPreviousPage: afterVersion !== null, + startCursor: edges[0]?.cursor ?? null, + endCursor: edges[edges.length - 1]?.cursor ?? null, + }, + totalCount, + }; + } + + /** + * Returns the access policy a MetaEnvelope carried at its latest recorded + * version, so its history stays guarded by the same rules once pruned. + */ + async getLatestMetaEnvelopeVersionAcl( + id: string, + eName: string, + ): Promise | null> { + if (!eName) { + throw new Error( + "eName is required for reading meta-envelope history", + ); + } + const result = await this.runQueryInternal( + ` + MATCH (v:MetaEnvelopeVersion { metaEnvelopeId: $id, eName: $eName }) + RETURN v.acl AS acl, v.aclBlock AS aclBlock + ORDER BY v.version DESC + LIMIT 1 + `, + { id, eName }, + ); + const record = result.records[0]; + if (!record) return null; + return { + acl: record.get("acl") ?? [], + _acl: parseStoredAclBlock(record.get("aclBlock")), + }; + } + /** * Finds meta-envelopes with Relay-style cursor pagination and optional filtering. * Supports filtering by ontology and searching within envelope values. diff --git a/infrastructure/evault-core/src/core/db/types.ts b/infrastructure/evault-core/src/core/db/types.ts index 926e37041..1ee9cb1c0 100644 --- a/infrastructure/evault-core/src/core/db/types.ts +++ b/infrastructure/evault-core/src/core/db/types.ts @@ -177,6 +177,27 @@ export type MetaEnvelopeConnection = Record = Record> = { + metaEnvelopeId: string; + version: number; + operation: "create" | "update" | "delete"; + ontology: string; + parsed: T | null; + requestingPlatform: string | null; + createdAt: string; +}; + +export type MetaEnvelopeVersionConnection = Record> = { + edges: { cursor: string; node: MetaEnvelopeVersion }[]; + pageInfo: PageInfo; + totalCount: number; +}; + /** * Options for paginated MetaEnvelope queries. */ diff --git a/infrastructure/evault-core/src/core/protocol/graphql-server.ts b/infrastructure/evault-core/src/core/protocol/graphql-server.ts index fc9235ed2..7da9c62f4 100644 --- a/infrastructure/evault-core/src/core/protocol/graphql-server.ts +++ b/infrastructure/evault-core/src/core/protocol/graphql-server.ts @@ -248,6 +248,26 @@ export class GraphQLServer { }, ), + // Every recorded version of a MetaEnvelope, including pruned ones + metaEnvelopeHistory: this.accessGuard.middleware( + ( + _: any, + args: { id: string; first?: number; after?: string }, + context: VaultContext, + ) => { + if (!context.eName) { + throw new Error("X-ENAME header is required"); + } + return this.db.getMetaEnvelopeVersions( + args.id, + context.eName, + { first: args.first, after: args.after }, + ); + }, + Permission.READ, + { includePruned: true }, + ), + // ============================================================ // LEGACY API (preserved for backward compatibility) // ============================================================ diff --git a/infrastructure/evault-core/src/core/protocol/typedefs.ts b/infrastructure/evault-core/src/core/protocol/typedefs.ts index afc8c3617..3cfb7fab5 100644 --- a/infrastructure/evault-core/src/core/protocol/typedefs.ts +++ b/infrastructure/evault-core/src/core/protocol/typedefs.ts @@ -95,6 +95,34 @@ export const typeDefs = /* GraphQL */ ` totalCount: Int } + "One recorded state of a MetaEnvelope. Versions are immutable and never removed." + type MetaEnvelopeVersion { + metaEnvelopeId: String! + "Starts at 1 and increases by one with every write to the record" + version: Int! + "create, update or delete. A delete means the record was pruned, and parsed is null" + operation: String! + "The ontology schema ID (W3ID) at this version" + ontology: String! + "The full payload as it stood after this write" + parsed: JSON + "The platform that made the write, when known" + requestingPlatform: String + "ISO-8601 time the version was recorded" + createdAt: String! + } + + type MetaEnvelopeVersionEdge { + cursor: String! + node: MetaEnvelopeVersion! + } + + type MetaEnvelopeVersionConnection { + edges: [MetaEnvelopeVersionEdge!]! + pageInfo: PageInfo! + totalCount: Int + } + # ============================================================================ # Search and Filter Types # ============================================================================ @@ -319,6 +347,19 @@ export const typeDefs = /* GraphQL */ ` before: String ): MetaEnvelopeConnection! + """ + The version history of a MetaEnvelope, newest first. Updates never + overwrite a recorded version and a removal only prunes the record, so + the history stays readable after removeMetaEnvelope. + """ + metaEnvelopeHistory( + id: ID! + "Number of versions to return (default 20, max 100)" + first: Int + "Cursor to start after" + after: String + ): MetaEnvelopeVersionConnection + # --- LEGACY API (preserved for backward compatibility) --- getMetaEnvelopeById(id: String!): MetaEnvelope findMetaEnvelopesByOntology(ontology: String!): [MetaEnvelope!]! diff --git a/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts b/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts index df6e31875..e7d70b09b 100644 --- a/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts +++ b/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts @@ -282,6 +282,7 @@ export class VaultAccessGuard { metaEnvelopeId: string, context: VaultContext, action: PermissionBits = Permission.READ, + includePruned = false, ): Promise<{ hasAccess: boolean; exists: boolean }> { // Reuse token payload already validated by validateAuthentication() earlier // in the middleware; only re-validate as a fallback (e.g. store operations @@ -307,10 +308,19 @@ export class VaultAccessGuard { throw new Error("X-ENAME header is required for access control"); } - const metaEnvelope = await this.db.findMetaEnvelopeById( - metaEnvelopeId, - context.eName, - ); + // A pruned record is gone from live reads, but its history is still + // guarded by the policy it last carried rather than left open. + const metaEnvelope = + (await this.db.findMetaEnvelopeById( + metaEnvelopeId, + context.eName, + )) ?? + (includePruned + ? await this.db.getLatestMetaEnvelopeVersionAcl( + metaEnvelopeId, + context.eName, + ) + : null); if (!metaEnvelope) { return { hasAccess: tokenPayload !== null, exists: false }; } @@ -410,6 +420,8 @@ export class VaultAccessGuard { /** * Middleware function to check access before executing a resolver * @param resolver - The resolver function to wrap + * @param options.includePruned - Decide access for a pruned record by the + * policy recorded in its history (used by history reads) * @returns A wrapped resolver that checks access before executing */ public middleware( @@ -419,6 +431,7 @@ export class VaultAccessGuard { context: VaultContext, ) => Promise, action: PermissionBits = Permission.READ, + options: { includePruned?: boolean } = {}, ) { return async (parent: T, args: Args, context: VaultContext) => { // Check if this is storeMetaEnvelope operation (has input with ontology, payload, acl) @@ -473,7 +486,12 @@ export class VaultAccessGuard { // Check if envelope exists and user has access const { hasAccess, exists } = await timed("guard.checkAccess", () => - this.checkAccess(metaEnvelopeId, context, action), + this.checkAccess( + metaEnvelopeId, + context, + action, + options.includePruned ?? false, + ), ); // For update operations with input, allow in-place creation if envelope doesn't exist diff --git a/skills/w3ds/reference/evault.md b/skills/w3ds/reference/evault.md index 9b25fa53c..d7b57a97c 100644 --- a/skills/w3ds/reference/evault.md +++ b/skills/w3ds/reference/evault.md @@ -111,6 +111,22 @@ mutation { } ``` +Removal prunes: the record leaves every read, but its data and version history are kept. + +### Version history + +Updates never overwrite a recorded state. Every create, update and remove appends an immutable version with the full payload after that write (`parsed: null` for a remove). Newest first: + +```graphql +query { + metaEnvelopeHistory(id: "global-id-123", first: 20) { + edges { node { version operation ontology parsed requestingPlatform createdAt } } + pageInfo { hasNextPage endCursor } + totalCount + } +} +``` + ### Bulk create For migrations and initial seeds only. Requires `Authorization: Bearer ` in addition to `X-ENAME`. Supports optional `id` per input (preserves IDs across migrations). From 231dd4a872a24e32014b8404229f6f8ee9ccfa94 Mon Sep 17 00:00:00 2001 From: coodos Date: Thu, 24 Sep 2026 16:03:18 +0530 Subject: [PATCH 4/4] test(evault): cover MetaEnvelope versioning, pruning and history --- .../src/core/db/db.service.spec.ts | 198 ++++++++++++++++++ .../protocol/idiomatic-graphql-api.spec.ts | 122 +++++++++++ .../core/protocol/vault-access-guard.spec.ts | 61 ++++++ 3 files changed, 381 insertions(+) diff --git a/infrastructure/evault-core/src/core/db/db.service.spec.ts b/infrastructure/evault-core/src/core/db/db.service.spec.ts index 9845735f9..385b30a92 100644 --- a/infrastructure/evault-core/src/core/db/db.service.spec.ts +++ b/infrastructure/evault-core/src/core/db/db.service.spec.ts @@ -1,5 +1,6 @@ import neo4j, { Driver } from "neo4j-driver"; import { DbService } from "./db.service"; // adjust if needed +import { createMetaEnvelopeVersionIndexes } from "./migrations/add-metaenvelope-version-indexes"; import { it, describe, beforeAll, afterAll, expect } from "vitest"; import { Neo4jContainer, StartedNeo4jContainer } from "@testcontainers/neo4j"; @@ -25,6 +26,7 @@ describe("DbService (integration)", () => { const uri = `bolt://localhost:${boltPort}`; driver = neo4j.driver(uri, neo4j.auth.basic(username, password)); + await createMetaEnvelopeVersionIndexes(driver); service = new DbService(driver); }, 120000); @@ -636,4 +638,200 @@ describe("DbService (integration)", () => { } }); }); + + describe("MetaEnvelope versioning", () => { + const VERSION_ENAME = "versions@example.com"; + + async function countNodes(label: string, id: string): Promise { + const result = await service.runQuery( + `MATCH (n:${label} { id: $id }) RETURN count(n) AS total`, + { id }, + ); + return result.records[0].get("total").toNumber(); + } + + it("records create, update and delete as immutable versions", async () => { + const stored = await service.storeMetaEnvelope( + { + ontology: "VersionedPost", + payload: { title: "first", body: "hello" }, + acl: ["*"], + }, + ["*"], + VERSION_ENAME, + { evaultPublicKey: null, requestingPlatform: "platform-a" }, + ); + const id = stored.metaEnvelope.id; + + await service.updateMetaEnvelopeById( + id, + { + ontology: "VersionedPost", + payload: { title: "second" }, + acl: ["*"], + }, + ["*"], + VERSION_ENAME, + ); + await service.deleteMetaEnvelope(id, VERSION_ENAME); + + const history = await service.getMetaEnvelopeVersions( + id, + VERSION_ENAME, + ); + expect(history.totalCount).toBe(3); + expect(history.edges.map((e) => e.node.version)).toEqual([3, 2, 1]); + expect(history.edges.map((e) => e.node.operation)).toEqual([ + "delete", + "update", + "create", + ]); + expect(history.edges[2].node.parsed).toEqual({ + title: "first", + body: "hello", + }); + expect(history.edges[2].node.requestingPlatform).toBe("platform-a"); + // An update records the full merged state, not just the patch + expect(history.edges[1].node.parsed).toEqual({ + title: "second", + body: "hello", + }); + expect(history.edges[0].node.parsed).toBeNull(); + }); + + it("prunes a deleted record instead of destroying it", async () => { + const stored = await service.storeMetaEnvelope( + { ontology: "PrunedPost", payload: { text: "keep me" }, acl: ["*"] }, + ["*"], + VERSION_ENAME, + ); + const id = stored.metaEnvelope.id; + const envelopeId = stored.envelopes[0].id; + + await service.deleteMetaEnvelope(id, VERSION_ENAME); + + expect(await service.findMetaEnvelopeById(id, VERSION_ENAME)).toBeNull(); + expect( + await service.findMetaEnvelopesByOntology("PrunedPost", VERSION_ENAME), + ).toEqual([]); + expect( + (await service.getAllEnvelopes(VERSION_ENAME)).some( + (e) => e.id === envelopeId, + ), + ).toBe(false); + const page = await service.findMetaEnvelopesPaginated(VERSION_ENAME, { + filter: { ontologyId: "PrunedPost" }, + }); + expect(page.totalCount).toBe(0); + + expect(await countNodes("MetaEnvelope", id)).toBe(0); + expect(await countNodes("PrunedMetaEnvelope", id)).toBe(1); + expect(await countNodes("Envelope", envelopeId)).toBe(0); + const pruned = await service.runQuery( + `MATCH (:PrunedMetaEnvelope { id: $id })-[:LINKS_TO]->(e:PrunedEnvelope) + RETURN e.value AS value`, + { id }, + ); + expect(pruned.records[0].get("value")).toBe("keep me"); + }); + + it("records a baseline for a record written before versioning", async () => { + const id = "legacy-meta-envelope-1"; + await service.runQuery( + `CREATE (m:MetaEnvelope { id: $id, ontology: 'LegacyPost', acl: ['*'], eName: $eName }) + CREATE (m)-[:LINKS_TO]->(:Envelope { id: 'legacy-envelope-1', ontology: 'text', value: 'before', valueType: 'string' })`, + { id, eName: VERSION_ENAME }, + ); + + await service.updateEnvelopeValue( + "legacy-envelope-1", + "after", + VERSION_ENAME, + ); + + const history = await service.getMetaEnvelopeVersions( + id, + VERSION_ENAME, + ); + expect(history.edges.map((e) => e.node.operation)).toEqual([ + "update", + "create", + ]); + expect(history.edges[1].node.parsed).toEqual({ text: "before" }); + expect(history.edges[0].node.parsed).toEqual({ text: "after" }); + }); + + it("prunes duplicate envelopes instead of deleting them", async () => { + const id = "duplicate-meta-envelope-1"; + await service.runQuery( + `CREATE (m:MetaEnvelope { id: $id, ontology: 'DupPost', acl: ['*'], eName: $eName }) + CREATE (m)-[:LINKS_TO]->(:Envelope { id: 'dup-envelope-a', ontology: 'text', value: 'a', valueType: 'string' }) + CREATE (m)-[:LINKS_TO]->(:Envelope { id: 'dup-envelope-b', ontology: 'text', value: 'b', valueType: 'string' })`, + { id, eName: VERSION_ENAME }, + ); + + await service.updateMetaEnvelopeById( + id, + { ontology: "DupPost", payload: { text: "c" }, acl: ["*"] }, + ["*"], + VERSION_ENAME, + ); + + const live = await service.findMetaEnvelopeById(id, VERSION_ENAME); + expect(live?.envelopes).toHaveLength(1); + expect(live?.parsed).toEqual({ text: "c" }); + const pruned = await service.runQuery( + `MATCH (:MetaEnvelope { id: $id })-[:LINKS_TO]->(e:PrunedEnvelope) RETURN count(e) AS total`, + { id }, + ); + expect(pruned.records[0].get("total").toNumber()).toBe(1); + }); + + it("keeps history isolated per eName", async () => { + const stored = await service.storeMetaEnvelope( + { ontology: "IsolatedPost", payload: { text: "mine" }, acl: ["*"] }, + ["*"], + VERSION_ENAME, + ); + const other = await service.getMetaEnvelopeVersions( + stored.metaEnvelope.id, + "someone-else@example.com", + ); + expect(other.totalCount).toBe(0); + expect(other.edges).toEqual([]); + }); + + it("paginates history newest first", async () => { + const stored = await service.storeMetaEnvelope( + { ontology: "PagedPost", payload: { n: 0 }, acl: ["*"] }, + ["*"], + VERSION_ENAME, + ); + const id = stored.metaEnvelope.id; + for (let n = 1; n <= 4; n++) { + await service.updateMetaEnvelopeById( + id, + { ontology: "PagedPost", payload: { n }, acl: ["*"] }, + ["*"], + VERSION_ENAME, + ); + } + + const page1 = await service.getMetaEnvelopeVersions(id, VERSION_ENAME, { + first: 2, + }); + expect(page1.edges.map((e) => e.node.version)).toEqual([5, 4]); + expect(page1.pageInfo.hasNextPage).toBe(true); + + const page2 = await service.getMetaEnvelopeVersions(id, VERSION_ENAME, { + first: 2, + after: page1.pageInfo.endCursor ?? undefined, + }); + expect(page2.edges.map((e) => e.node.version)).toEqual([3, 2]); + expect(page2.edges.map((e) => e.node.parsed)).toEqual([ + { n: 2 }, + { n: 1 }, + ]); + }); + }); }); diff --git a/infrastructure/evault-core/src/core/protocol/idiomatic-graphql-api.spec.ts b/infrastructure/evault-core/src/core/protocol/idiomatic-graphql-api.spec.ts index 4da9aaef5..81f66e90d 100644 --- a/infrastructure/evault-core/src/core/protocol/idiomatic-graphql-api.spec.ts +++ b/infrastructure/evault-core/src/core/protocol/idiomatic-graphql-api.spec.ts @@ -754,6 +754,128 @@ describe("Idiomatic GraphQL API", () => { }); }); + describe("metaEnvelopeHistory query", () => { + const HISTORY_QUERY = ` + query History($id: ID!, $first: Int, $after: String) { + metaEnvelopeHistory(id: $id, first: $first, after: $after) { + edges { + cursor + node { metaEnvelopeId version operation ontology parsed createdAt } + } + pageInfo { hasNextPage endCursor } + totalCount + } + } + `; + + it("keeps every version and stays readable after removal", async () => { + const created = await makeGraphQLRequest( + server, + `mutation Create($input: MetaEnvelopeInput!) { + createMetaEnvelope(input: $input) { metaEnvelope { id } } + }`, + { + input: { + ontology: "HistoryTestOntology", + payload: { title: "v1", body: "unchanged" }, + acl: ["*"], + }, + }, + getAuthHeaders(), + ); + const id = created.createMetaEnvelope.metaEnvelope.id; + + await makeGraphQLRequest( + server, + `mutation Update($id: ID!, $input: MetaEnvelopeInput!) { + updateMetaEnvelope(id: $id, input: $input) { errors { message } } + }`, + { + id, + input: { + ontology: "HistoryTestOntology", + payload: { title: "v2" }, + acl: ["*"], + }, + }, + getAuthHeaders(), + ); + + const removed = await makeGraphQLRequest( + server, + `mutation Remove($id: ID!) { + removeMetaEnvelope(id: $id) { success } + }`, + { id }, + getAuthHeaders(), + ); + expect(removed.removeMetaEnvelope.success).toBe(true); + + // Gone from every live read + const live = await makeGraphQLRequest( + server, + `query Live($id: ID!) { metaEnvelope(id: $id) { id } }`, + { id }, + getAuthHeaders(), + ); + expect(live.metaEnvelope).toBeNull(); + const listed = await makeGraphQLRequest( + server, + `query List { metaEnvelopes(filter: { ontologyId: "HistoryTestOntology" }) { totalCount } }`, + {}, + getAuthHeaders(), + ); + expect(listed.metaEnvelopes.totalCount).toBe(0); + + // ...but its history is still there, newest first + const result = await makeGraphQLRequest( + server, + HISTORY_QUERY, + { id }, + getAuthHeaders(), + ); + const history = result.metaEnvelopeHistory; + expect(history.totalCount).toBe(3); + expect( + history.edges.map((e: any) => [e.node.version, e.node.operation]), + ).toEqual([ + [3, "delete"], + [2, "update"], + [1, "create"], + ]); + expect(history.edges[0].node.parsed).toBeNull(); + expect(history.edges[1].node.parsed).toEqual({ + title: "v2", + body: "unchanged", + }); + expect(history.edges[2].node.parsed).toEqual({ + title: "v1", + body: "unchanged", + }); + expect(history.edges[2].node.metaEnvelopeId).toBe(id); + + const page = await makeGraphQLRequest( + server, + HISTORY_QUERY, + { id, first: 1, after: history.edges[0].cursor }, + getAuthHeaders(), + ); + expect(page.metaEnvelopeHistory.edges[0].node.version).toBe(2); + expect(page.metaEnvelopeHistory.pageInfo.hasNextPage).toBe(true); + }); + + it("requires authentication", async () => { + await expect( + makeGraphQLRequest( + server, + HISTORY_QUERY, + { id: "any-id" }, + { "X-ENAME": evault.w3id }, + ), + ).rejects.toThrow(); + }); + }); + describe("Envelope.fieldKey resolver", () => { it("should return fieldKey as alias for ontology", async () => { const mutation = ` diff --git a/infrastructure/evault-core/src/core/protocol/vault-access-guard.spec.ts b/infrastructure/evault-core/src/core/protocol/vault-access-guard.spec.ts index ab99f92b3..c684a0840 100644 --- a/infrastructure/evault-core/src/core/protocol/vault-access-guard.spec.ts +++ b/infrastructure/evault-core/src/core/protocol/vault-access-guard.spec.ts @@ -1420,4 +1420,65 @@ describe("group grants and denials end to end", () => { ).resolves.toBeDefined(); }); }); + + describe("history of a pruned record", () => { + const PLATFORM = "@platform-history"; + const OTHER_PLATFORM = "@platform-history-other"; + + const storeAndPrune = async (eName: string, _acl: any) => { + const result = await dbService.storeMetaEnvelope( + { ontology: "Test", payload: { field: "value" }, acl: ["*"], _acl }, + ["*"], + eName, + ); + await dbService.deleteMetaEnvelope(result.metaEnvelope.id, eName); + return result.metaEnvelope.id; + }; + + const contextFor = async (eName: string, platform: string) => { + const token = await createValidToken({ platform }); + return createMockContext({ + eName, + request: { + headers: new Headers({ authorization: `Bearer ${token}` }), + } as any, + }); + }; + + const policy = { + v: 1, + grants: [{ ename: PLATFORM, perms: 0x01 }], + denials: { enames: [OTHER_PLATFORM], conditions: [] }, + default_perms: 0x00, + require: [], + }; + + it("admits a party the pruned record's last policy allowed", async () => { + const eName = "@vault-history-1"; + const id = await storeAndPrune(eName, policy); + + const context = await contextFor(eName, PLATFORM); + const resolver = vi.fn(async () => ({ edges: [] })); + await expect( + guard.middleware(resolver, Permission.READ, { + includePruned: true, + })(null, { id }, context), + ).resolves.toBeDefined(); + expect(resolver).toHaveBeenCalled(); + }); + + it("denies a party the pruned record's last policy denied", async () => { + const eName = "@vault-history-2"; + const id = await storeAndPrune(eName, policy); + + const context = await contextFor(eName, OTHER_PLATFORM); + const resolver = vi.fn(async () => ({ edges: [] })); + await expect( + guard.middleware(resolver, Permission.READ, { + includePruned: true, + })(null, { id }, context), + ).rejects.toThrow("Access denied"); + expect(resolver).not.toHaveBeenCalled(); + }); + }); });