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
21 changes: 20 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,26 @@ By far most changes relate to `atomic-server`, so if not specified, assume the c
**Changes to JS assets (including the front-end and JS libraries) are not shown here**, but in [`/browser/CHANGELOG`](/browser/CHANGELOG.md).
See [STATUS.md](server/STATUS.md) to learn more about which features will remain stable.

## UNRELEASED
- Commits are signed envelopes, not a queryable event log. Ordinary content
commits are not stored as resources after apply (genesis and
rights/parent/destroy stay). Loro binaries are not KV-index keys. The
`/commits` collection is not created. UI reads author/dates from the
resource, not by fetching `did:ad:commit:` rows. A creation commit is
retained whether or not the client flagged `isGenesis`. Clients no longer chain
`previousCommit`; apply no longer has a previous-commit validation gate.
History no longer offers a "Show Commit" link at a discarded envelope.
- **Signed envelopes live on the resource (`Tree::Envelopes`).** Every
signed commit's JSON-AD is kept per resource, keyed by createdAt and
signature, in the same transaction as the state it signs. Not a
resource, not indexed. `--envelope-retention` / `ATOMIC_ENVELOPE_RETENTION`
is `latest` (the envelope that produced the current state; default) or
`all` (every envelope: a signed audit log). `GET /history-attribution?subject=`
(read-gated) answers who signed which Loro change, verified with the
apply code, plus whether every change is covered. Rust builder commits
and `create_did` now tag their Loro change like the browser does, so
History maps versions to signers. The destroy envelope on the tombstone
(added for `SYNC_DIFF.removeCommits`) is now the subject's latest row
in this tree. Envelopes do not yet travel in bulk sync or the vault.

- **Missing-drive bootstrap is no longer a free pass (OQ5).** A
`SYNC_PUSH` or live write for a drive this node has never stored goes
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ _Status: alpha. [Breaking changes](CHANGELOG.md) are expected until 1.0._
- 📄 **Documents**, collaborative, rich text, similar to Google Docs / Notion.
- 💬 **Group chat**, performant and flexible message channels with attachments, search and replies.
- 📂 **File management**: Upload, download and preview attachments.
- 💾 **Event-sourced versioning** / history powered by [Atomic Commits](https://docs.atomicdata.dev/commits/intro.html)
- 💾 **Versioning** / history from the Loro oplog, with writes authorized by [Atomic Commits](https://docs.atomicdata.dev/commits/intro.html)
- 🔄 **Real-time synchronization**: instantly communicates state changes with a client. Build dynamic, collaborative apps using [websockets](https://docs.atomicdata.dev/websockets) (using a [single one-liner in react](https://docs.atomicdata.dev/usecases/react) or [svelte](https://docs.atomicdata.dev/svelte)).
- 🧰 **Many serialization options**: to JSON, [JSON-AD](https://docs.atomicdata.dev/core/json-ad.html), and various Linked Data / RDF formats (RDF/XML, N-Triples / Turtle / JSON-LD).
- 📖 **Pagination, sorting and filtering** queries using [Atomic Collections](https://docs.atomicdata.dev/schema/collections.html).
Expand Down
11 changes: 11 additions & 0 deletions TESTING_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ Two things worth knowing about the runners:
| Engine-owned `SUB`/`UNSUB`: granted `SUB` is a session command, unreadable `SUB` answers `ERROR UNAUTHORIZED_READ` | `lib/src/sync/engine.rs` (`bootstrap_and_sub_tests`) |
| Signed `SYNC_DIFF.removeCommits`: envelope applies regardless of connection agent, tampered envelope does not delete, envelope only handed to drive readers, replay after re-creation refused | `lib/src/sync/peer.rs` (`initiator_trust_tests`), `engine.rs` (`bootstrap_and_sub_tests`), `tombstones.rs`, `protocol.rs` |
| `SyncSession` over an in-process `AtomicTransport` holds `AUTH` across frames | `lib/src/sync/session.rs` |
| Signed envelopes per resource: `latest`/`all` retention, time order, not indexed, verified attribution per Loro token, tampered envelope unverified, two writers, destroy fold | `lib/src/envelopes.rs` |
| `GET /history-attribution` names the verified signer and is read-gated | `server/tests/it/history_attribution.rs` |
| Attribution parse / version lookup / server+local merge | `browser/lib/src/history-attribution.test.ts` |
| Engine-level two-store sync, private drives, blobs, live push | `lib/src/sync/tests.rs` |
| RBSR reconciliation, drive hashing | `lib/src/sync/rbsr.rs`, `tests.rs` |
| RBSR finds a remote-only subject sorting below every local one | `lib/src/sync/rbsr.rs` **and** `browser/lib/src/rbsr.test.ts` (regression, see below) |
Expand Down Expand Up @@ -348,6 +351,14 @@ Not covered: derived AI tools invoked through a real model; MCP protocol project

Not covered: leftover Yjs-era DocumentV2 bodies end-to-end (needs a stored `{ type: 'ydoc' }` fixture); read-only v1 documents stay on the element list and have no e2e.

## Commits as envelopes

| Flow | Layer | Where |
|---|---|---|
| `LoroDoc` values are not KV-index keys | protocol | `lib/src/values.rs::loro_doc_is_not_indexed` |
| Content commits are not stored; genesis/ACL/destroy are | protocol | `lib/src/db/test.rs::content_commits_are_not_stored` |
| Sequential saves do not chain `previousCommit`; commit DIDs are not store resources | glue | `browser/lib/src/commit.test.ts` |

## Personal drive identity

| Flow | Where |
Expand Down
11 changes: 11 additions & 0 deletions browser/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ This changelog covers all five packages, as they are (for now) updated as a whol

## UNRELEASED

- Commits are signed envelopes: `CommitDetail` does not fetch `did:ad:commit:`
resources; author and dates come from the resource. History no longer
navigates to a commit DID, and the Sync page log no longer links commit ids.
Sequential saves no longer set `previousCommit`.
- History shows who signed each version. `Store.getHistoryAttribution(subject)`
merges the server's `GET /history-attribution` with the local ClientDb
(`historyAttribution`), and `VersionTitle` labels a version
`by <agent> Verified` (signature checked), `Unverified` (envelope present,
signature failed) or `by peer … Unattributed` (no envelope covers it).
`attributionForVersion`, `mergeHistoryAttributions`, `parseHistoryAttribution`
and the `HistoryAttribution` / `Attribution` types are exported from `@tomic/lib`.
- [#1232](https://github.com/ontola/atomic-server/issues/1232) Unified
actions: the ⌘K palette shows a capped Actions section for the current
resource, hotkeys and the shortcuts overlay/page render from the action
Expand Down
83 changes: 9 additions & 74 deletions browser/data-browser/src/components/CommitDetail.tsx
Original file line number Diff line number Diff line change
@@ -1,113 +1,48 @@
import {
commits,
useDate,
useResource,
useString,
type Commits,
} from '@tomic/react';
import { ResourceInline } from '../views/ResourceInline';
import { Detail } from './Detail';
import { DateTime, DateTimeRelative } from './datatypes/DateTime';
import { AtomicLink } from './AtomicLink';

import type { JSX } from 'react';

type Props = {
commitSubject?: string;
short?: boolean;
/**
* Date read from an intrinsic resource propval (e.g. a message's
* `createdAt`). When provided it is used as the displayed date — and
* rendered immediately, without waiting on the commit fetch. This is what
* lets the timestamp survive a refresh: under the DID / sign-at-drain model
* a `did:ad:commit:<sig>` resource is no longer refetchable, so a date that
* depends on the commit load disappears on reload. See
* `planning/commit-retention-and-state-certificates.md` ("History / audit
* UI").
* Date read from the resource itself (genesis `createdAt`). No commit
* fetch — `did:ad:commit:` is not a queryable resource.
*/
createdAt?: Date;
/**
* Creator (an agent subject) read from the resource itself — e.g.
* `useCreatedBy`, which derives it from the genesis Loro change. Preferred
* over the fetched commit's `signer` so the creator survives a refresh
* without refetching the (no-longer-refetchable) commit.
* `useCreatedBy`, which derives it from the genesis certificate.
*/
createdBy?: string;
};

/** Shows the latest editor and edit date */
/** Shows the editor and date from resource-derived metadata. */
export function CommitDetail({
commitSubject,
short,
createdAt,
createdBy,
}: Props): JSX.Element | null {
// Only fetch the commit when the caller hasn't already supplied the creation
// metadata from the resource's own oplog (createdAt + createdBy). Under
// sign-at-drain a `did:ad:commit:<sig>` resource is no longer refetchable, so
// when both are provided we skip the fetch entirely — it would only fail and
// (pre-fix) blanked the creator/date on refresh. Other callers (generic
// "last edited" displays) pass neither and still get the commit-fetch path.
const needsCommit = createdAt === undefined || createdBy === undefined;
const resource = useResource<Commits.Commit>(
needsCommit ? commitSubject : undefined,
);
const [signer] = useString(resource, commits.properties.signer);
const [previousCommit] = useString(
resource,
commits.properties.previousCommit,
);

// Prefer the resource-derived creator; fall back to the commit's signer.
const creator = createdBy ?? signer;

const commitCreatedAt = useDate(resource, commits.properties.createdAt);
const date = createdAt ?? commitCreatedAt;

if (!commitSubject && !date) {
if (!createdAt) {
return null;
}

// Wait for a date. With an explicit `createdAt` we render right away (no
// commit needed); otherwise the date comes from the commit, so wait for it.
if (!date || (createdAt === undefined && resource.loading)) {
return <Detail>-</Detail>;
}

// Only link the date to the commit on the legacy commit-fetch path. When the
// metadata is resource-derived (createdAt/createdBy supplied), the commit is
// irrelevant — and under sign-at-drain that link wouldn't resolve — so render
// the date as plain text instead.
const linkToCommit = needsCommit && !!commitSubject;

if (short) {
return (
<Detail>
{linkToCommit ? (
<AtomicLink subject={commitSubject!}>
<DateTimeRelative date={date} />
</AtomicLink>
) : (
<DateTimeRelative date={date} />
)}
<DateTimeRelative date={createdAt} />
</Detail>
);
}

const dateElement = <DateTime date={date} />;
const dateElement = <DateTime date={createdAt} />;

return (
<Detail>
{creator && <ResourceInline subject={creator} />}
{createdBy && <ResourceInline subject={createdBy} />}
{'-'}
{linkToCommit ? (
<AtomicLink subject={commitSubject!}>
{previousCommit ? 'edited ' : ''}
{dateElement}
</AtomicLink>
) : (
dateElement
)}{' '}
{dateElement}{' '}
</Detail>
);
}
17 changes: 7 additions & 10 deletions browser/data-browser/src/components/EditableTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,16 +156,13 @@ export function EditableTitle({
}, [isEditing, text]);

// The keystroke debounce in `useValue` (`commitDebounce: 100ms`)
// means the commit for the last typed character is still parked in
// a `setTimeout` when the user exits the editor. The next interaction
// — back-to-back rename, route change, reload — can run before the
// timer fires, so a quick "type → Escape → type again" sequence ends
// up with the second value chained onto the wrong `previousCommit`
// and the server only keeping the first one. Force-flush on every
// exit path (Enter, Escape, blur) so the commit posts before the
// editor unmounts. `save()` is a no-op when there are no dirty
// changes, so the still-armed debounce timer that fires afterwards
// is harmless.
// means the last typed character is still parked in a `setTimeout`
// when the user exits the editor. The next interaction — back-to-back
// rename, route change, reload — can run before the timer fires and
// drop that last keystroke. Force-flush on every exit path (Enter,
// Escape, blur) so the commit posts before the editor unmounts.
// `save()` is a no-op when there are no dirty changes, so the still-
// armed debounce timer that fires afterwards is harmless.
const flushPending = () => {
void resource.__internalObject.save().catch(() => undefined);
};
Expand Down
49 changes: 39 additions & 10 deletions browser/data-browser/src/locales/de.po
Original file line number Diff line number Diff line change
Expand Up @@ -1998,8 +1998,8 @@ msgid "Class"
msgstr ""

#: src/views/FolderPage/ListView.tsx
msgid "Last Modified"
msgstr ""
msgid "Created"
msgstr "Erstellt"

#: src/views/Article/ArticlePage.tsx
msgid "Children"
Expand Down Expand Up @@ -2717,10 +2717,6 @@ msgstr ""
msgid "Restore this version"
msgstr ""

#: src/routes/History/HistoryDesktopView.tsx
msgid "Show Commit"
msgstr ""

#. 0: resource.title
#. 0: resource.title
#: src/routes/History/HistoryDesktopView.tsx
Expand Down Expand Up @@ -3188,10 +3184,8 @@ msgstr ""
msgid "Could not save resource"
msgstr ""

#. 0: version.peer && <> by peer {version.peer.slice(0, 8)}...</>; 1: version.message && <> — {version.message}</>
#: src/routes/History/VersionTitle.tsx
msgid "Edited <0/> {0} {1}"
msgstr ""
#~ msgid "Edited <0/> {0} {1}"
#~ msgstr ""

#: src/views/OntologyPage/Property/PropertyLineRead.tsx
msgid "Property does not exist anymore"
Expand Down Expand Up @@ -7262,3 +7256,38 @@ msgstr ""
#: src/components/Vault/LinkProviderPanel.tsx
msgid "stores it without being able to read it."
msgstr ""

#: src/routes/History/VersionTitle.tsx
msgid "Verified"
msgstr ""

#: src/routes/History/VersionTitle.tsx
msgid "Unverified"
msgstr ""

#: src/routes/History/VersionTitle.tsx
msgid "Signature checked against the signer's key"
msgstr ""

#: src/routes/History/VersionTitle.tsx
msgid "Envelope present, but its signature did not verify"
msgstr ""

#: src/routes/History/VersionTitle.tsx
msgid "No signed envelope covers this change on this node"
msgstr ""

#. 0: ' '; 1: signed ? ( <SignedBy attribution={signed} /> ) \\: ( <UnattributedBy peer={version.peer} /> ); 2: version.message && !signed && <> — {version.message}</>
#: src/routes/History/VersionTitle.tsx
msgid "Edited <0/>{0} {1} {2}"
msgstr ""

#. 0: ' '
#: src/routes/History/VersionTitle.tsx
msgid "by <0/>{0} <1/>"
msgstr ""

#. 0: shortPeer; 1: ' '
#: src/routes/History/VersionTitle.tsx
msgid "by peer {0}{1} <0>Unattributed</0>"
msgstr ""
49 changes: 39 additions & 10 deletions browser/data-browser/src/locales/en.po
Original file line number Diff line number Diff line change
Expand Up @@ -1998,8 +1998,8 @@ msgid "Class"
msgstr "Class"

#: src/views/FolderPage/ListView.tsx
msgid "Last Modified"
msgstr "Last Modified"
msgid "Created"
msgstr "Created"

#: src/views/Article/ArticlePage.tsx
msgid "Children"
Expand Down Expand Up @@ -2717,10 +2717,6 @@ msgstr "History of"
msgid "Restore this version"
msgstr "Restore this version"

#: src/routes/History/HistoryDesktopView.tsx
msgid "Show Commit"
msgstr "Show Commit"

#. 0: resource.title
#. 0: resource.title
#: src/routes/History/HistoryDesktopView.tsx
Expand Down Expand Up @@ -3188,10 +3184,8 @@ msgstr "Next item"
msgid "Could not save resource"
msgstr "Could not save resource"

#. 0: version.peer && <> by peer {version.peer.slice(0, 8)}...</>; 1: version.message && <> — {version.message}</>
#: src/routes/History/VersionTitle.tsx
msgid "Edited <0/> {0} {1}"
msgstr "Edited <0/> {0} {1}"
#~ msgid "Edited <0/> {0} {1}"
#~ msgstr "Edited <0/> {0} {1}"

#: src/views/OntologyPage/Property/PropertyLineRead.tsx
msgid "Property does not exist anymore"
Expand Down Expand Up @@ -7296,3 +7290,38 @@ msgstr "This app cannot sign in on its own, so approve it from somewhere you alr
#: src/components/Vault/LinkProviderPanel.tsx
msgid "stores it without being able to read it."
msgstr "stores it without being able to read it."

#: src/routes/History/VersionTitle.tsx
msgid "Verified"
msgstr "Verified"

#: src/routes/History/VersionTitle.tsx
msgid "Unverified"
msgstr "Unverified"

#: src/routes/History/VersionTitle.tsx
msgid "Signature checked against the signer's key"
msgstr "Signature checked against the signer's key"

#: src/routes/History/VersionTitle.tsx
msgid "Envelope present, but its signature did not verify"
msgstr "Envelope present, but its signature did not verify"

#: src/routes/History/VersionTitle.tsx
msgid "No signed envelope covers this change on this node"
msgstr "No signed envelope covers this change on this node"

#. 0: ' '; 1: signed ? ( <SignedBy attribution={signed} /> ) \\: ( <UnattributedBy peer={version.peer} /> ); 2: version.message && !signed && <> — {version.message}</>
#: src/routes/History/VersionTitle.tsx
msgid "Edited <0/>{0} {1} {2}"
msgstr "Edited <0/>{0} {1} {2}"

#. 0: ' '
#: src/routes/History/VersionTitle.tsx
msgid "by <0/>{0} <1/>"
msgstr "by <0/>{0} <1/>"

#. 0: shortPeer; 1: ' '
#: src/routes/History/VersionTitle.tsx
msgid "by peer {0}{1} <0>Unattributed</0>"
msgstr "by peer {0}{1} <0>Unattributed</0>"
Loading
Loading