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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions docs/docs/Infrastructure/eVault.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ This graph always holds the current state. History is kept alongside it, never l

```cypher
(MetaEnvelopeHistory {metaEnvelopeId, eName, latestVersion})
(MetaEnvelopeVersion {metaEnvelopeId, eName, version, operation, ontology, acl, aclBlock, payloadJson, requestingPlatform, createdAt})
(MetaEnvelopeVersion {metaEnvelopeId, eName, version, operation, ontology, acl, aclBlock, payloadJson, fieldsJson, requestingPlatform, author, restoredFromVersion, createdAt})
```

A removed record is relabelled `PrunedMetaEnvelope`, and its Envelopes `PrunedEnvelope`, so no read returns it while its data is kept.
Expand Down Expand Up @@ -213,6 +213,8 @@ query {
ontology
parsed
requestingPlatform
author
restoredFromVersion
createdAt
}
}
Expand All @@ -226,7 +228,9 @@ query {
```

- `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.
- `requestingPlatform` is the platform that made the write. `author` is the user it said it acted for, taken from `X-ON-BEHALF-OF` or from a wallet-signed token. It is the caller's assertion, recorded for history, not proof of identity.
- `restoredFromVersion` is set on a version written by `rollbackMetaEnvelope`.
- Records written before versioning existed get a baseline `create` version from their state at their first later write. Its platform and author are unknown.

### Mutations

Expand Down Expand Up @@ -297,6 +301,30 @@ mutation {
}
```

#### rollbackMetaEnvelope

Restore a MetaEnvelope to the state it had at an earlier version. Nothing is rewritten: the restore is written as a new version on top of the history, so the version number keeps increasing and the rollback can itself be rolled back.

- The payload is replaced exactly. Unlike `updateMetaEnvelope`, which patches, fields the earlier version did not have are pruned.
- The record keeps the access policy it has now, so restoring old data never restores old access.
- Rolling back a removed record brings it back. Access to it is decided by the policy it last carried.
- A version that records a removal cannot be restored. Use `removeMetaEnvelope` instead.
- Requires UPDATE permission.

**Mutation**:
```graphql
mutation {
rollbackMetaEnvelope(id: "global-id-123", version: 2) {
metaEnvelope { id parsed }
version
restoredFromVersion
errors { field message code }
}
}
```

Error codes: `VERSION_NOT_FOUND`, `VERSION_IS_DELETE`, `INVALID_VERSION`, `ROLLBACK_FAILED`.

#### removeMetaEnvelope

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.
Expand Down Expand Up @@ -594,7 +622,7 @@ curl -X GET http://localhost:4000/whois \

### /logs

Get paginated envelope operation logs for an eName. Each log entry describes a create, update, delete, or update_envelope_value operation (metaEnvelope id, hash, operation type, platform, timestamp).
Get paginated envelope operation logs for an eName. Each log entry describes a create, update, delete, or update_envelope_value operation (metaEnvelope id, hash, operation type, platform, author, timestamp). `author` is the user the writing platform said it acted for, or `null`..

**Request**:
```http
Expand All @@ -619,6 +647,7 @@ Optional query parameters:
"envelopeHash": "sha256-hex",
"operation": "create",
"platform": "https://platform.example.com",
"author": "@user-a.w3id",
"timestamp": "2025-02-04T12:00:00.000Z",
"ontology": "550e8400-e29b-41d4-a716-446655440001"
}
Expand Down
220 changes: 220 additions & 0 deletions infrastructure/evault-core/src/core/db/db.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -833,5 +833,225 @@ describe("DbService (integration)", () => {
{ n: 1 },
]);
});

it("records who made each write", async () => {
const stored = await service.storeMetaEnvelope(
{ ontology: "AuthoredPost", payload: { text: "a" }, acl: ["*"] },
["*"],
VERSION_ENAME,
{
evaultPublicKey: null,
requestingPlatform: "@platform-one",
author: "@user-one",
skipAwareness: true,
},
);
await service.updateMetaEnvelopeById(
stored.metaEnvelope.id,
{ ontology: "AuthoredPost", payload: { text: "b" }, acl: ["*"] },
["*"],
VERSION_ENAME,
{
evaultPublicKey: null,
requestingPlatform: "@platform-two",
skipAwareness: true,
},
);

const history = await service.getMetaEnvelopeVersions(
stored.metaEnvelope.id,
VERSION_ENAME,
);
expect(
history.edges.map((e) => [
e.node.requestingPlatform,
e.node.author,
]),
).toEqual([
["@platform-two", null],
["@platform-one", "@user-one"],
]);
});
});

describe("MetaEnvelope rollback", () => {
const ROLLBACK_ENAME = "rollback@example.com";
const origin = {
evaultPublicKey: null,
requestingPlatform: "@undo-platform",
author: "@undo-user",
skipAwareness: true,
};

it("restores an earlier state exactly as a new version", async () => {
const stored = await service.storeMetaEnvelope(
{
ontology: "RollbackPost",
payload: {
title: "original",
publishedAt: new Date("2026-01-02T03:04:05Z"),
},
acl: ["*"],
},
["*"],
ROLLBACK_ENAME,
);
const id = stored.metaEnvelope.id;
const titleEnvelopeId = stored.envelopes.find(
(e) => e.ontology === "title",
)?.id;

await service.updateMetaEnvelopeById(
id,
{
ontology: "RollbackPost",
payload: { title: "edited", tags: ["added", "later"] },
acl: ["*"],
},
["*"],
ROLLBACK_ENAME,
);

const result = await service.rollbackMetaEnvelope(
id,
ROLLBACK_ENAME,
1,
origin,
);
expect(result.version).toBe(3);
expect(result.operation).toBe("update");
expect(result.restoredFromVersion).toBe(1);

const live = await service.findMetaEnvelopeById(id, ROLLBACK_ENAME);
// The field added after v1 is gone and the date is a date again
expect(Object.keys(live?.parsed ?? {}).sort()).toEqual([
"publishedAt",
"title",
]);
expect(live?.parsed.title).toBe("original");
expect(live?.parsed.publishedAt).toBeInstanceOf(Date);
expect(
(live?.parsed.publishedAt as Date).toISOString(),
).toBe("2026-01-02T03:04:05.000Z");
// Envelope ids stay stable for fields that survive
expect(
live?.envelopes.find((e) => e.ontology === "title")?.id,
).toBe(titleEnvelopeId);

const history = await service.getMetaEnvelopeVersions(
id,
ROLLBACK_ENAME,
);
expect(history.totalCount).toBe(3);
const latest = history.edges[0].node;
expect(latest.operation).toBe("update");
expect(latest.restoredFromVersion).toBe(1);
expect(latest.author).toBe("@undo-user");
expect(latest.requestingPlatform).toBe("@undo-platform");
expect(history.edges[1].node.restoredFromVersion).toBeNull();
});

it("keeps the current access policy instead of the old one", async () => {
const stored = await service.storeMetaEnvelope(
{ ontology: "AclPost", payload: { text: "v1" }, acl: ["@old-reader"] },
["@old-reader"],
ROLLBACK_ENAME,
);
const id = stored.metaEnvelope.id;
await service.updateMetaEnvelopeById(
id,
{ ontology: "AclPost", payload: { text: "v2" }, acl: ["@new-reader"] },
["@new-reader"],
ROLLBACK_ENAME,
);

await service.rollbackMetaEnvelope(id, ROLLBACK_ENAME, 1, origin);

const live = await service.findMetaEnvelopeById(id, ROLLBACK_ENAME);
expect(live?.parsed).toEqual({ text: "v1" });
expect(live?.acl).toEqual(["@new-reader"]);
});

it("brings a removed record back", async () => {
const stored = await service.storeMetaEnvelope(
{ ontology: "RevivedPost", payload: { text: "alive" }, acl: ["*"] },
["*"],
ROLLBACK_ENAME,
);
const id = stored.metaEnvelope.id;
await service.deleteMetaEnvelope(id, ROLLBACK_ENAME);
expect(await service.findMetaEnvelopeById(id, ROLLBACK_ENAME)).toBeNull();

const result = await service.rollbackMetaEnvelope(
id,
ROLLBACK_ENAME,
1,
origin,
);
expect(result.operation).toBe("create");
expect(result.version).toBe(3);

const live = await service.findMetaEnvelopeById(id, ROLLBACK_ENAME);
expect(live?.parsed).toEqual({ text: "alive" });
expect(live?.acl).toEqual(["*"]);
const history = await service.getMetaEnvelopeVersions(
id,
ROLLBACK_ENAME,
);
expect(history.edges.map((e) => e.node.operation)).toEqual([
"create",
"delete",
"create",
]);
});

it("rejects a version that does not exist or records a removal", async () => {
const stored = await service.storeMetaEnvelope(
{ ontology: "RejectPost", payload: { text: "x" }, acl: ["*"] },
["*"],
ROLLBACK_ENAME,
);
const id = stored.metaEnvelope.id;

await expect(
service.rollbackMetaEnvelope(id, ROLLBACK_ENAME, 9, origin),
).rejects.toMatchObject({ code: "VERSION_NOT_FOUND" });

await service.deleteMetaEnvelope(id, ROLLBACK_ENAME);
await expect(
service.rollbackMetaEnvelope(id, ROLLBACK_ENAME, 2, origin),
).rejects.toMatchObject({ code: "VERSION_IS_DELETE" });

// A failed rollback records nothing
const history = await service.getMetaEnvelopeVersions(
id,
ROLLBACK_ENAME,
);
expect(history.totalCount).toBe(2);
});

it("restores versions recorded before typed fields existed", async () => {
const stored = await service.storeMetaEnvelope(
{ ontology: "UntypedPost", payload: { text: "first", n: 1 }, acl: ["*"] },
["*"],
ROLLBACK_ENAME,
);
const id = stored.metaEnvelope.id;
await service.runQuery(
`MATCH (v:MetaEnvelopeVersion { metaEnvelopeId: $id }) REMOVE v.fieldsJson`,
{ id },
);
await service.updateMetaEnvelopeById(
id,
{ ontology: "UntypedPost", payload: { text: "second", n: 2 }, acl: ["*"] },
["*"],
ROLLBACK_ENAME,
);

await service.rollbackMetaEnvelope(id, ROLLBACK_ENAME, 1, origin);

const live = await service.findMetaEnvelopeById(id, ROLLBACK_ENAME);
expect(live?.parsed).toEqual({ text: "first", n: 1 });
});
});
});
Loading
Loading