Edit history from formerRepresentations

Pleroma and Akkoma send a post's earlier versions with it. A post PrivaPub first meets after its edits now has its
history, and an edit that carries them brings the versions missed in between; each is read as the post itself is. The
Akkoma scenario checks a post fetched after two edits by an account no persona follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-06 12:07:49 +02:00
1 parent 02928caac1
commit 679463eb3f
12 files changed
+280 -16

No files matched your search

+1 -1
View File
@@ -518,7 +518,7 @@ tools/pasture/run.sh down # removes e
shipped in the release (CAStore, certifi), so the entrypoint appends Caddy's CA there. `pleroma_ctl` passes its
arguments on unquoted: no value may contain a space. Its Linkify never takes `@user@host.test` for a mention, so the
scenario addresses alice with Pleroma's `to[]`; its API never reports a remote blocker as `blocked_by`, so the block is
read from its `user_relationships`. 44 checks.
read from its `user_relationships`. `akkoma_token <user>` signs one of its users in. 45 checks.
- **Lemmy (1.0.0-beta.2):** the backend alone on the shared Postgres, its admin made by `setup` in the generated
`config.hjson`. It trusts Caddy's CA through `SSL_CERT_FILE` and reaches the network through
`DANGER_FEDERATION_ALLOW_LOCAL_IP=1`; 0.19 cannot join (its rustls trusts only its bundled roots). Its API is
+2
View File
@@ -248,6 +248,8 @@ tags.
content, one per choice, counted once per voter. Counts are refreshed with an `Update{Question}` at most every three
minutes. Our own votes on other servers' polls are sent the same way to the poll's author only, without `published`.
An incoming `Update` without a newer `updated` only refreshes counts and details; it is never recorded as an edit.
A post's earlier versions in `formerRepresentations` (Pleroma, Akkoma) are its edit history, read as the post itself is:
a post first met after its edits has them, and an edit that carries them brings the versions missed in between.
**Quotes** (FEP-044f, with the older keys):
- **Received.** A quote is read from `quote`, `quoteUrl`, `quoteUri`, `_misskey_quote` or a FEP-e232 `Link` tag.
@@ -0,0 +1,97 @@
using MongoDB.Entities;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
// Pleroma's and Akkoma's edited posts carry their earlier versions (formerRepresentations): a post first met after its
// edits has its history, and an edit tells the ones missed in between
[Trait("Category", "Integration")]
public sealed class FormerRepresentationsTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
static readonly DateTime Start = DateTime.UtcNow.AddHours(-1);
static JsonObject Version(string text, int minutes) => new()
{
["type"] = "Note", ["content"] = $"<p>{text}</p>", ["published"] = Start.ToString("O"),
["updated"] = minutes == 0 ? default : Start.AddMinutes(minutes).ToString("O")
};
async Task<(RemoteActor Carol, JsonObject Note)> Followed(string text, int minutes, params JsonObject[] former)
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var carol = new RemoteActor(_harness.Peer, "carol");
await DB.Default.SaveAsync(new Following { AvatarId = alice.Id, TargetActorURI = carol.Id, State = FollowState.Accepted }, token);
var note = Version(text, minutes);
note["id"] = $"{Origin(carol)}/objects/{Guid.NewGuid():N}";
note["attributedTo"] = carol.Id;
note["to"] = new JsonArray(Addressing.Public);
note["cc"] = new JsonArray($"{carol.Id}/followers");
if (former.Length > 0)
note["formerRepresentations"] = new JsonObject { ["type"] = "OrderedCollection", ["totalItems"] = former.Length, ["orderedItems"] = new JsonArray(former) };
return (carol, note);
}
Task Deliver(RemoteActor carol, string type, JsonObject note) => _harness.Deliver(carol, "/human-centipede", new JsonObject
{
["id"] = $"{Origin(carol)}/activities/{Guid.NewGuid():N}", ["type"] = type, ["actor"] = carol.Id,
["to"] = new JsonArray(Addressing.Public), ["object"] = note.DeepClone()
});
Task<Post> Stored(JsonObject note) =>
DB.Default.Find<Post>().Match(p => p.ObjectURI == note["id"]!.GetValue<string>()).ExecuteSingleAsync(TestContext.Current.CancellationToken);
[Fact]
public async Task A_post_first_met_after_its_edits_has_its_history()
{
var (carol, note) = await Followed("third", 40, Version("second", 20), Version("first", 0));
await Deliver(carol, "Create", note);
var post = await Stored(note);
Assert.Equal(["<p>first</p>", "<p>second</p>"], post.Revisions.Select(r => r.ContentHtml));
Assert.Equal("<p>third</p>", post.ContentHtml);
}
[Fact]
public async Task An_edit_tells_the_versions_missed_in_between()
{
var (carol, note) = await Followed("first", 0);
await Deliver(carol, "Create", note);
var edited = (JsonObject)note.DeepClone();
edited["content"] = "<p>third</p>";
edited["updated"] = Start.AddMinutes(40).ToString("O");
edited["formerRepresentations"] = new JsonObject
{
["type"] = "OrderedCollection", ["totalItems"] = 2, ["orderedItems"] = new JsonArray(Version("second", 20), Version("first", 0))
};
await Deliver(carol, "Update", edited);
var post = await Stored(note);
Assert.Equal(["<p>first</p>", "<p>second</p>"], post.Revisions.Select(r => r.ContentHtml));
Assert.Equal("<p>third</p>", post.ContentHtml);
}
}
}
@@ -90,6 +90,36 @@ namespace PrivaPub.Tests.Federation
Assert.Equal(["https://privapub.test/peasants/flor_pp98"], note.Cc);
}
// Pleroma's edited post carries its earlier versions (formerRepresentations, newest first)
[Fact]
public void Reads_the_earlier_versions_of_a_pleroma_edit()
{
var note = NoteParser.Parse(JsonNode.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "pleroma", "edited.json"))));
var before = Assert.Single(note.FormerRepresentations);
Assert.StartsWith("tomorrow library garden bridge", before.ContentHtml);
Assert.Equal(new DateTime(2026, 10, 5, 0, 49, 58, DateTimeKind.Utc).AddTicks(8_600_710), before.EditedAt);
Assert.Null(before.SpoilerText);
Assert.EndsWith("(edited)", note.ContentHtml);
}
[Fact]
public void Earlier_versions_come_oldest_first_each_read_as_the_post_is()
{
var note = NoteParser.Parse(JsonNode.Parse("""
{"id":"https://pl.example/objects/1","type":"Note","attributedTo":"https://pl.example/users/carol","content":"<p>third</p>",
"published":"2026-10-06T08:00:00Z","updated":"2026-10-06T09:00:00Z",
"formerRepresentations":{"type":"OrderedCollection","totalItems":2,"orderedItems":[
{"type":"Note","content":"<p>second</p>","summary":"careful","published":"2026-10-06T08:00:00Z","updated":"2026-10-06T08:30:00Z"},
{"type":"Note","content":"<p>first<script>x</script></p>","summary":"","published":"2026-10-06T08:00:00Z"}]}}
"""));
Assert.Equal(["<p>first</p>", "<p>second</p>"], note.FormerRepresentations.Select(r => r.ContentHtml));
Assert.Equal([new DateTime(2026, 10, 6, 8, 0, 0, DateTimeKind.Utc), new DateTime(2026, 10, 6, 8, 30, 0, DateTimeKind.Utc)],
note.FormerRepresentations.Select(r => r.EditedAt));
Assert.Equal([(default(string), false), ("careful", true)], note.FormerRepresentations.Select(r => (r.SpoilerText, r.HasContentWarning)));
}
[Fact]
public void Reads_a_lemmy_page_with_a_title()
{
@@ -0,0 +1,71 @@
{
"actor": "https://pleroma.test/users/flor_pl97",
"attachment": [],
"attributedTo": "https://pleroma.test/users/flor_pl97",
"cc": [
"https://pleroma.test/users/flor_pl97/followers"
],
"content": "mountain late window map bright postcard (p10) (edited)",
"context": "https://pleroma.test/contexts/891653c5-91ba-43f3-b3ef-b066caa3fa4d",
"conversation": "https://pleroma.test/contexts/891653c5-91ba-43f3-b3ef-b066caa3fa4d",
"formerRepresentations": {
"orderedItems": [
{
"actor": "https://pleroma.test/users/flor_pl97",
"attachment": [],
"attributedTo": "https://pleroma.test/users/flor_pl97",
"cc": [
"https://pleroma.test/users/flor_pl97/followers"
],
"content": "tomorrow library garden bridge the festival bicycle together garden early quiet morning library the postcard tomorrow mountain the small rain postcard warm garden market map mountain map (p10)",
"contentMap": {
"en": "tomorrow library garden bridge the festival bicycle together garden early quiet morning library the postcard tomorrow mountain the small rain postcard warm garden market map mountain map (p10)"
},
"context": "https://pleroma.test/contexts/891653c5-91ba-43f3-b3ef-b066caa3fa4d",
"conversation": "https://pleroma.test/contexts/891653c5-91ba-43f3-b3ef-b066caa3fa4d",
"published": "2026-10-05T00:49:58.860071Z",
"repliesCount": 2,
"source": {
"content": "tomorrow library garden bridge the festival bicycle together garden early quiet morning library the postcard tomorrow mountain the small rain postcard warm garden market map mountain map (p10)",
"mediaType": "text/plain"
},
"summary": "",
"tag": [],
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"type": "Note"
}
],
"totalItems": 1,
"type": "OrderedCollection"
},
"id": "https://pleroma.test/objects/6101c4fd-ed46-461c-a3fe-9742852f47da",
"published": "2026-10-05T00:49:58.860071Z",
"replies": {
"first": {
"id": "https://pleroma.test/objects/6101c4fd-ed46-461c-a3fe-9742852f47da/replies?page=true",
"orderedItems": [
"https://pleroma.test/objects/b7b7c066-51a8-46d1-8919-07b87f158887",
"https://privapub.test/peasants/vera_pp40/scribbles/6ac2f4520c61eaf3ab241d35"
],
"partOf": "https://pleroma.test/objects/6101c4fd-ed46-461c-a3fe-9742852f47da/replies",
"type": "OrderedCollectionPage"
},
"id": "https://pleroma.test/objects/6101c4fd-ed46-461c-a3fe-9742852f47da/replies",
"totalItems": 2,
"type": "OrderedCollection"
},
"repliesCount": 2,
"source": {
"content": "mountain late window map bright postcard (p10) (edited)",
"mediaType": "text/plain"
},
"summary": "",
"tag": [],
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"type": "Note",
"updated": "2026-10-05T00:52:40.105051Z"
}
+5 -1
View File
@@ -13,7 +13,7 @@ namespace PrivaPub.Federation.Inbox
{
public static class RemoteEdits
{
public const int MaxRevisions = 20;
public const int MaxRevisions = NoteParser.MaxRevisions;
//newer than what we hold; or, for a first edit, no older and actually different, because GoToSocial's timestamps are
//whole seconds and an edit made in the second the post was published carries updated == published
@@ -47,6 +47,10 @@ namespace PrivaPub.Federation.Inbox
return false;
}
//the author's server's own history (Pleroma's, Akkoma's) holds the version we had, and the edits we missed
if (note.FormerRepresentations.Count > 0)
post.Revisions = note.FormerRepresentations.ToList();
else
post.Revisions.Add(new PostRevision
{
Title = post.Title,
+2 -1
View File
@@ -133,7 +133,8 @@ namespace PrivaPub.Federation.Inbox
AuthorGone = author.AccountState == AvatarAccountState.Deleted,
CreationDate = note.Published,
UpdateDate = note.Updated ?? note.Published,
EditedAt = note.Updated
EditedAt = note.Updated,
Revisions = note.FormerRepresentations.ToList()
};
await Episode(post, note, author, token);
return post;
+40 -4
View File
@@ -52,6 +52,7 @@ namespace PrivaPub.Federation.Objects
public EventDetails Event { get; init; }
public PostPlace Place { get; init; }
public bool? CommentsEnabled { get; init; }//Lemmy, PieFed and Mbin: false once its moderators lock it; null when not said
public IReadOnlyList<PostRevision> FormerRepresentations { get; init; } = Array.Empty<PostRevision>();//its earlier versions, oldest first
}
public static class NoteParser
@@ -59,6 +60,7 @@ namespace PrivaPub.Federation.Objects
static readonly string[] NoteTypes = { "Note", "Article", "Page", "Question", "Event", "Video", "Audio", "Image", "ChatMessage", "PodcastEpisode" };
const int MaxAttachments = 16;
const int MaxTags = 64;
public const int MaxRevisions = 20;
public static NoteDocument Parse(JsonNode node)
{
@@ -75,8 +77,7 @@ namespace PrivaPub.Federation.Objects
var summary = Value(note, "summary");
var excerpt = ObjectShapes.Text(summary, 1000)
?? (note["preview"] is JsonObject preview && Value(preview, "type") == "Note" ? ObjectShapes.Text(Value(preview, "content"), 1000) : default);
var sensitive = note["sensitive"] is JsonValue flag && flag.TryGetValue<bool>(out var marked) && marked;
var summaryWarns = type is "Note" or "Question" || sensitive;
var (sensitive, summaryWarns) = Warns(note, type, summary);
return new NoteDocument
{
Id = id,
@@ -87,7 +88,7 @@ namespace PrivaPub.Federation.Objects
Title = ObjectShapes.Text(Value(note, "name"), 500),
SpoilerText = summaryWarns ? Plain(summary, 500) : default,
Excerpt = summaryWarns ? default : excerpt,
Sensitive = sensitive || summaryWarns && !string.IsNullOrWhiteSpace(summary),
Sensitive = sensitive,
Language = language,
Url = UrlOf(note["url"]) ?? id,
InReplyTo = Id(note["inReplyTo"]),
@@ -128,10 +129,45 @@ namespace PrivaPub.Federation.Objects
Audio = ObjectShapes.Audio(type == "PodcastEpisode" ? note["audio"] as JsonObject : note),
Event = ObjectShapes.Event(note),
Place = ObjectShapes.NotePlace(note),
CommentsEnabled = note["commentsEnabled"] is JsonValue comments && comments.TryGetValue<bool>(out var enabled) ? enabled : null
CommentsEnabled = note["commentsEnabled"] is JsonValue comments && comments.TryGetValue<bool>(out var enabled) ? enabled : null,
FormerRepresentations = Former(note, type)
};
}
// a summary is a content warning only on a Note or a Question, or a post marked sensitive; elsewhere it is an excerpt
static (bool Sensitive, bool SummaryWarns) Warns(JsonObject note, string type, string summary)
{
var marked = note["sensitive"] is JsonValue flag && flag.TryGetValue<bool>(out var value) && value;
var warns = type is "Note" or "Question" || marked;
return (marked || warns && !string.IsNullOrWhiteSpace(summary), warns);
}
// Pleroma's and Akkoma's earlier versions of an edited post (`formerRepresentations`, newest first there), each read as
// the post itself is: the newest twenty, oldest first, each dated when it was written
static List<PostRevision> Former(JsonObject note, string type)
{
if (note["formerRepresentations"] is not JsonObject former || former["orderedItems"] is not JsonArray items)
return [];
return items.OfType<JsonObject>().Take(MaxRevisions * 2)
.Select(version =>
{
var summary = Value(version, "summary");
var (sensitive, warns) = Warns(version, type, summary);
return new PostRevision
{
Title = ObjectShapes.Text(Value(version, "name"), 500),
SpoilerText = warns ? Plain(summary, 500) : default,
ContentHtml = ContentSanitizer.Html(Content(version).Html),
HasContentWarning = sensitive,
EditedAt = Time(Value(version, "updated")) ?? Time(Value(version, "published")) ?? default
};
})
.Reverse()
.OrderBy(r => r.EditedAt)
.TakeLast(MaxRevisions)
.ToList();
}
// Castopod's PodcastEpisode: its words in `description` (a Note of its own), its sound in `audio`, its title the
// sound's name; read as a post with those
static JsonObject Episode(JsonObject episode)
+4 -3
View File
@@ -411,13 +411,13 @@ collections answer HTML to an ActivityPub `Accept`, so PrivaPub shows no counts
| Gap | P | Client surface |
|---|---|---|
| Language from `@context[].@language` | P1 | `Status.language` |
| `formerRepresentations` → our revisions | P2 | `/statuses/:id/history` |
| `formerRepresentations` → our revisions: **done 2026-10-06** (a post met after its edits has its history; an edit brings the versions missed) | P2 | `/statuses/:id/history` |
| `context`/`conversation` threading; parents and quotes we could not fetch, kept as URIs | P2 | `pleroma.context`; `akkoma.in_reply_to_apid`, `akkoma.quote_apid` precedent |
| `ChatMessage` in as a direct message (also needed for Lemmy, Mbin and PieFed); advertise `acceptsChatMessages` only once it is answered | P1 (in), P3 (out) | `visibility: direct`, Conversations |
| `Listen`, `vcard:bday`, `backgroundUrl` | P3 | own |
**Pasture evidence (2026-10-03, Akkoma 3.20.1, `tools/pasture/scenarios/akkoma.sh`):** 44 checks pass, three runs in a
row. Akkoma publishes no image, so `tools/pasture/images/akkoma` installs its OTP release, pinned by checksum. Covered:
row (45 since 2026-10-06). Akkoma publishes no image, so `tools/pasture/images/akkoma` installs its OTP release, pinned by checksum. Covered:
- discovery and follows both ways;
- posts, CW and followers-only posts;
- the `published` time kept;
@@ -428,7 +428,8 @@ row. Akkoma publishes no image, so `tools/pasture/images/akkoma` installs its OT
- polls and votes both ways;
- quotes both ways (`quote_id` out of its API, `quoteUri` in ours);
- images with alt text both ways;
- edits with history, and deletes, both ways;
- edits with history, and deletes, both ways; a post PrivaPub first meets after two edits, by an account no persona
follows, has its history from `formerRepresentations` (2026-10-06);
- unfollow, block and unblock;
- statistics.
+2 -1
View File
@@ -677,7 +677,8 @@ it, raw where it doesn't.
receiving server, and a signed roll-call listing only them; honoured: a sender's digest of its followers here,
mended from its list; checked live against Mastodon, all four ways a follow can be lost);
- `indexable`/`discoverable`/`searchableBy`;
- edit history from `formerRepresentations`;
- edit history from `formerRepresentations`: **done 2026-10-06** (Pleroma's and Akkoma's earlier versions, for a post
met after its edits and for the edits missed in between);
- PeerTube reply rules and `ApproveReply`.
- **Events:** structured RSVP (`Join`/`Leave` with stable ids): **done 2026-10-05** (owner decision above), checked live
against Mobilizon.
+9 -3
View File
@@ -17,13 +17,19 @@ akkoma_up() {
done
podman exec pasture-akkoma /opt/akkoma/bin/pleroma_ctl user new akuser akuser@akkoma.test --password Akkoma-Pasture-Pass-1 \
--name akuser --admin --assume-yes >/dev/null 2>&1 || true
akkoma_token akuser > "$here/.state/akkoma.token" || true
echo "akkoma: https://akkoma.test:6443"
}
# akkoma_token <user>: an access token for one of the pasture's users (its password), through a new app and the password
# grant
akkoma_token() {
local app cid cs
app=$(site akkoma.test -s -X POST https://akkoma.test:6443/api/v1/apps -d 'client_name=pasture&redirect_uris=urn:ietf:wg:oauth:2.0:oob&scopes=read write follow push admin')
cid=$(echo "$app" | python3 -c "import sys,json; print(json.load(sys.stdin)['client_id'])" 2>/dev/null)
cs=$(echo "$app" | python3 -c "import sys,json; print(json.load(sys.stdin)['client_secret'])" 2>/dev/null)
site akkoma.test -s -X POST https://akkoma.test:6443/oauth/token --data-urlencode grant_type=password --data-urlencode username=akuser \
site akkoma.test -s -X POST https://akkoma.test:6443/oauth/token --data-urlencode grant_type=password --data-urlencode "username=$1" \
--data-urlencode password=Akkoma-Pasture-Pass-1 --data-urlencode "client_id=$cid" --data-urlencode "client_secret=$cs" \
--data-urlencode 'scope=read write follow push admin' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])" > "$here/.state/akkoma.token" 2>/dev/null || true
echo "akkoma: https://akkoma.test:6443"
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])" 2>/dev/null
}
+17 -2
View File
@@ -1,8 +1,10 @@
# Akkoma 3.20: discovery, follows, posts, CW, replies both ways, likes and boosts with their undos, emoji reactions
# both ways, DMs, polls, quotes both ways, edits with their history, deletes, media with alt text, the published time
# kept, blocks, statistics. Akkoma speaks the Mastodon API, with reactions under /api/v1/pleroma.
# both ways, DMs, polls, quotes both ways, edits with their history (a post met after its edits too), deletes, media
# with alt text, the published time kept, blocks, statistics. Akkoma speaks the Mastodon API, with reactions under
# /api/v1/pleroma.
AK=https://akkoma.test:6443
acurl() { curl -sk --resolve akkoma.test:6443:127.0.0.1 "$@"; }
. "$here/peers/akkoma.sh"
KT=$(cat "$here/.state/akkoma.token" 2>/dev/null)
KH="Authorization: Bearer $KT"
# what Akkoma holds of an account, newest first; and the one status whose uri is given
@@ -122,6 +124,19 @@ curl -s -o /dev/null -X PUT -H "$PH" "$P/api/v1/statuses/$p_post_id" -d 'status=
until_true 30 'acurl -H "$KH" "$AK/api/v1/statuses/$p_post_on_a" | grep -q "edited"' && ok "alice_akkoma's edit reaches Akkoma" || ko "edit not applied on Akkoma"
acurl -o /dev/null -X PUT -H "$KH" "$AK/api/v1/statuses/$a_post_id" -d 'status=Hello PrivaPub from Akkoma, edited'
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$a_post_on_p/history" | j "print(len(d))")" = "2" ]' && ok "akuser's edit reaches PrivaPub with its history" || ko "Akkoma's edit not applied on PrivaPub"
# a post PrivaPub first meets after two edits, by an Akkoma account no persona follows, comes with its earlier versions
# (formerRepresentations)
podman exec pasture-akkoma /opt/akkoma/bin/pleroma_ctl user new akwriter akwriter@akkoma.test --password Akkoma-Pasture-Pass-1 \
--name akwriter --assume-yes >/dev/null 2>&1 || true
WH="Authorization: Bearer $(akkoma_token akwriter)"
written=$(date +%s)
w_post=$(acurl -X POST -H "$WH" "$AK/api/v1/statuses" -d "status=written thrice $written&visibility=public" | j "print(d['id'])")
acurl -o /dev/null -X PUT -H "$WH" "$AK/api/v1/statuses/$w_post" -d "status=written thrice $written, again"
acurl -o /dev/null -X PUT -H "$WH" "$AK/api/v1/statuses/$w_post" -d "status=written thrice $written, at last"
w_uri=$(acurl -H "$WH" "$AK/api/v1/statuses/$w_post" | j "print(d['uri'])")
w_on_p=$(curl -s -G -H "$PH" "$P/api/v2/search" --data-urlencode "q=$w_uri" -d resolve=true -d type=statuses | j "print(d['statuses'][0]['id'])")
[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$w_on_p/history" | j "print([e['content'].split('$written')[-1].strip(' ,</p>') for e in d])")" = "['', 'again', 'at last']" ] \
&& ok "a post met after its edits has its history from Akkoma" || ko "a post met after its edits lacks its history ($(curl -s -H "$PH" "$P/api/v1/statuses/$w_on_p/history" | head -c 300))"
cw_on_a=$(a_status_by_uri "$alice_on_a" "$cw_uri" | j "print(d['id'])")
cw_id=$(curl -s -H "$PH" "$P/api/v1/accounts/$alice_self/statuses" | j "print(next(s['id'] for s in d if s['uri']=='$cw_uri'))")
curl -s -o /dev/null -X DELETE -H "$PH" "$P/api/v1/statuses/$cw_id"