From ff43eece7aacf0b77c85226b5170a5032e3f6ca8 Mon Sep 17 00:00:00 2001 From: feruzm Date: Mon, 7 Sep 2026 12:32:52 +0000 Subject: [PATCH 1/3] Let a curation mark carry the lane it was made in The desk's hand-off gives each curator's position and, from esync #54, the lane it was earned in: "PeakD only", "new authors", the whole queue. The lane rides on the mark request rather than living per curator, because a per-curator row is keyed by username and the last feed request from any tab or device would decide the lane every later mark was stamped with. Two tabs on different filters stamped the wrong lane on one of them. Sent with the mark, each desk describes itself and there is nothing to race over. The Mark route gains one optional key, `lane`, an object. It is rebuilt from an allow list of the roster-feed keys that describe WHICH posts, not how they are paged, and cleaned with the same rules a roster-feed body gets: unknown sorts dropped, views, apps and windows checked against their sets, the community against its grammar, the numeric bounds clamped. Nothing outside the list is copied, so a key the feed does not know never travels. A lane that is not an object is refused as a client bug; an absent lane is forwarded as absent, which the backend records as unknown. The backend validates it again through its own parser, so this is the first of two gates, not the only one. --- .../CurationDeskPayloadTests.cs | 40 +++++++++++++ .../Handlers/PrivateApi.CurationDesk.cs | 58 ++++++++++++++++++- 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs b/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs index f03f01f2..49e873bb 100644 --- a/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs +++ b/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs @@ -128,6 +128,46 @@ public void OnlyWhitelistedKeysTravel() Assert.Equal(new[] { "username", "since", "need", "visible" }, tick.Select(kv => kv.Key).ToArray()); } + /// + /// A mark carries the lane the desk was showing. It is rebuilt from the allow + /// list and cleaned like a roster-feed body, so a key the feed does not know, + /// a value it would refuse, and the paging fields never reach the backend. + /// + [Fact] + public void MarkLaneIsRebuiltFromTheAllowListAndNormalized() + { + var payload = Ok(CurationDeskWrites.Mark, + "{\"author\":\"bob\",\"permlink\":\"p\",\"state\":\"reviewed\",\"lane\":{" + + "\"app\":\"peakd\",\"sort\":\"unique\",\"new_authors\":\"1\",\"rep_min\":250," + + "\"window\":\"bogus\",\"community\":\"../etc\",\"cursor\":\"c1\",\"limit\":5,\"seed\":\"abcd1234\"," + + "\"admin\":true,\"username\":\"mallory\"}}"); + var lane = Assert.IsType(payload["lane"]); + Assert.Equal(new[] { "app", "sort", "rep_min", "new_authors" }, lane.Select(kv => kv.Key).ToArray()); + Assert.Equal("peakd", lane["app"]!.GetValue()); + Assert.Equal("unique", lane["sort"]!.GetValue()); + Assert.Equal(100, lane["rep_min"]!.GetValue()); + Assert.Equal(new[] { "username", "author", "permlink", "state", "lane" }, payload.Select(kv => kv.Key).ToArray()); + } + + [Fact] + public void MarkWithoutALaneStaysWithoutOne() + { + var payload = Ok(CurationDeskWrites.Mark, "{\"author\":\"bob\",\"permlink\":\"p\",\"state\":\"reviewed\"}"); + Assert.False(payload.ContainsKey("lane")); + // and an empty object is a real answer: the whole queue + var whole = Ok(CurationDeskWrites.Mark, "{\"author\":\"bob\",\"permlink\":\"p\",\"state\":\"reviewed\",\"lane\":{}}"); + Assert.Empty(Assert.IsType(whole["lane"])); + } + + [Theory] + [InlineData("{\"author\":\"bob\",\"permlink\":\"p\",\"state\":\"reviewed\",\"lane\":\"peakd\"}")] + [InlineData("{\"author\":\"bob\",\"permlink\":\"p\",\"state\":\"reviewed\",\"lane\":[\"peakd\"]}")] + [InlineData("{\"author\":\"bob\",\"permlink\":\"p\",\"state\":\"reviewed\",\"lane\":7}")] + public void MarkLaneMustBeAnObject(string body) + { + Assert.Equal("lane must be an object", Rejected(CurationDeskWrites.Mark, body)); + } + [Theory] [InlineData("{\"permlink\":\"p\",\"state\":\"reviewed\"}", "author required")] [InlineData("{\"author\":\"\",\"permlink\":\"p\",\"state\":\"reviewed\"}", "author required")] diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs b/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs index 717b896b..603d480c 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs @@ -777,8 +777,24 @@ public sealed record Route(string UpstreamPath, string[] Keys, bool ForwardClien public static readonly Route Tick = new("curation/desk/tick", new[] { "since", "need", "visible" }); + /// + /// A mark carries the feed params the desk that made it was showing, as one + /// `lane` object, so the hand-off can say which queue a position was earned in. + /// It travels on the mark rather than living per curator on purpose: two desks + /// on different filters then stamp each mark with their own, with nothing to + /// race over. The object is rebuilt from an allow list and normalized exactly + /// like a roster-feed body, minus paging, before it goes upstream. + /// public static readonly Route Mark = new("curation/desk/marks", - new[] { "author", "permlink", "state", "reason", "note", "snooze_until" }); + new[] { "author", "permlink", "state", "reason", "note", "snooze_until", "lane" }); + + /// The roster-feed keys that describe WHICH posts, not how they are paged. + public static readonly string[] LaneKeys = + { + "view", "app", "community", "min_words", "sort", "window", "rep_min", "rep_max", "max_words", + "has_images", "new_authors", "recommended", "flagged", "hide_curated", "hide_reviewed", + "hide_snoozed", + }; public static readonly Route MarkClear = new("curation/desk/marks/clear", new[] { "author", "permlink" }); @@ -828,6 +844,11 @@ public static (JsonObject? Payload, string? Error) Build(Route route, string use NormalizeRosterFeed(payload, body); } + if (ReferenceEquals(route, Mark) && body.Field("lane") is JsonObject lane) + { + payload["lane"] = NormalizeLane(lane); + } + if (ReferenceEquals(route, Tick)) { // The backend caps both lists at this many ids; truncating here @@ -885,6 +906,34 @@ private static void NormalizeRosterFeed(JsonObject payload, JsonObject body) Clamp(payload, "max_words", 0, CurationDeskQuery.MaxWords); } + /// + /// The lane a mark was made in, rebuilt from the allow list and cleaned with the + /// same rules as a roster-feed body. Nothing the feed would refuse reaches the + /// backend, and nothing outside is copied at all. + /// + private static JsonObject NormalizeLane(JsonObject lane) + { + var clean = new JsonObject(); + foreach (var key in LaneKeys) + { + CopyIfPresent(clean, lane, key); + } + var sort = lane.Str("sort"); + if (sort == null || !RosterSorts.Contains(sort)) + { + clean.Remove("sort"); + } + KeepAllowed(clean, "view", RosterViews); + KeepAllowed(clean, "app", CurationDeskQuery.Apps); + KeepAllowed(clean, "window", CurationDeskQuery.Windows); + KeepMatching(clean, "community", CurationDeskQuery.IsCommunity); + Clamp(clean, "rep_min", 0, 100); + Clamp(clean, "rep_max", 0, 100); + Clamp(clean, "min_words", 0, CurationDeskQuery.MaxWords); + Clamp(clean, "max_words", 0, CurationDeskQuery.MaxWords); + return clean; + } + /// Drop a field whose value is not one of . private static void KeepAllowed(JsonObject payload, string key, IReadOnlySet allowed) { @@ -954,6 +1003,13 @@ private static void Truncate(JsonObject payload, string key, int max) { if (ReferenceEquals(route, Mark)) { + // The lane is optional, and absent is the honest answer for a desk that + // predates it; but a lane that is not an object is a client bug, not a + // lane, and the backend would only store NULL for it anyway. + if (body.TryGetPropertyValue("lane", out var lane) && lane is not JsonObject) + { + return "lane must be an object"; + } return RequireAuthorPermlink(body) ?? RequireOneOf(body, "state", MarkStates); } if (ReferenceEquals(route, MarkClear)) From 5c33d24c19f35934e5c8376dc7bef82a57ba3a17 Mon Sep 17 00:00:00 2001 From: feruzm Date: Mon, 7 Sep 2026 12:47:02 +0000 Subject: [PATCH 2/3] Carry a sort in the lane only when it narrows An order is not a lane. The Random order arrives with a seed the lane never carries, and the backend's feed parser refuses that pairing, so forwarding sort=random would have turned every mark made on that order into a 400 once all three repos were live. Only unique travels, the one sort the backend folds into a narrowing. --- .../CurationDeskPayloadTests.cs | 20 +++++++++++++++++++ .../Handlers/PrivateApi.CurationDesk.cs | 7 +++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs b/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs index 49e873bb..3d48d048 100644 --- a/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs +++ b/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs @@ -149,6 +149,26 @@ public void MarkLaneIsRebuiltFromTheAllowListAndNormalized() Assert.Equal(new[] { "username", "author", "permlink", "state", "lane" }, payload.Select(kv => kv.Key).ToArray()); } + /// + /// The Random order arrives with a seed the lane never carries, and the backend's + /// feed parser refuses that pairing. An order is not a lane, so only the one sort + /// that narrows travels. + /// + [Theory] + [InlineData("random", false)] + [InlineData("newest", false)] + [InlineData("queue", false)] + [InlineData("unique", true)] + public void MarkLaneCarriesASortOnlyWhenItNarrows(string sort, bool travels) + { + var payload = Ok(CurationDeskWrites.Mark, + $"{{\"author\":\"bob\",\"permlink\":\"p\",\"state\":\"reviewed\",\"lane\":{{\"sort\":\"{sort}\",\"seed\":\"abcd1234\",\"app\":\"peakd\"}}}}"); + var lane = Assert.IsType(payload["lane"]); + Assert.Equal(travels, lane.ContainsKey("sort")); + Assert.False(lane.ContainsKey("seed")); + Assert.Equal("peakd", lane["app"]!.GetValue()); + } + [Fact] public void MarkWithoutALaneStaysWithoutOne() { diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs b/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs index 603d480c..b6b5800e 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs @@ -918,8 +918,11 @@ private static JsonObject NormalizeLane(JsonObject lane) { CopyIfPresent(clean, lane, key); } - var sort = lane.Str("sort"); - if (sort == null || !RosterSorts.Contains(sort)) + // An order is not a lane. The one sort that narrows is `unique`, which the + // backend folds into `recommended`; every other value is dropped, and with + // it the only way a lane could carry `sort=random` without the seed the + // backend's feed parser would demand for it. + if (lane.Str("sort") != "unique") { clean.Remove("sort"); } From 76eb749626fd4b68c0db59793d692f080a41afae Mon Sep 17 00:00:00 2001 From: feruzm Date: Mon, 7 Sep 2026 12:50:30 +0000 Subject: [PATCH 3/3] Let the lane carry any known order, never the seed The order decides whether a position is a watermark at all: a mark on newest-first says nothing about the older posts, so the hand-off has to be able to say which order it was made in. The previous commit dropped every sort but unique to keep random-without-seed away from the backend's feed parser; the backend now reads the sort off the lane on its own, so any known sort travels and the seed still never does. --- .../EcencyApi.Tests/CurationDeskPayloadTests.cs | 15 ++++++++------- .../EcencyApi/Handlers/PrivateApi.CurationDesk.cs | 11 ++++++----- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs b/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs index 3d48d048..0ce218de 100644 --- a/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs +++ b/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs @@ -150,16 +150,17 @@ public void MarkLaneIsRebuiltFromTheAllowListAndNormalized() } /// - /// The Random order arrives with a seed the lane never carries, and the backend's - /// feed parser refuses that pairing. An order is not a lane, so only the one sort - /// that narrows travels. + /// The order travels, because it decides whether a position is a watermark: a mark + /// on newest-first says nothing about the older posts. The seed never travels, and + /// the backend reads the sort without its feed parser's seed rule. /// [Theory] - [InlineData("random", false)] - [InlineData("newest", false)] - [InlineData("queue", false)] + [InlineData("random", true)] + [InlineData("newest", true)] + [InlineData("queue", true)] [InlineData("unique", true)] - public void MarkLaneCarriesASortOnlyWhenItNarrows(string sort, bool travels) + [InlineData("payout", false)] + public void MarkLaneCarriesAKnownSortAndNeverTheSeed(string sort, bool travels) { var payload = Ok(CurationDeskWrites.Mark, $"{{\"author\":\"bob\",\"permlink\":\"p\",\"state\":\"reviewed\",\"lane\":{{\"sort\":\"{sort}\",\"seed\":\"abcd1234\",\"app\":\"peakd\"}}}}"); diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs b/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs index b6b5800e..04f73aad 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs @@ -918,11 +918,12 @@ private static JsonObject NormalizeLane(JsonObject lane) { CopyIfPresent(clean, lane, key); } - // An order is not a lane. The one sort that narrows is `unique`, which the - // backend folds into `recommended`; every other value is dropped, and with - // it the only way a lane could carry `sort=random` without the seed the - // backend's feed parser would demand for it. - if (lane.Str("sort") != "unique") + // The order travels, because it decides whether a position is a watermark + // at all: a mark on newest-first says nothing about the older posts. The seed + // does not travel, and the backend reads the sort off the lane on its own + // rather than through its feed parser, so random without a seed is fine here. + var sort = lane.Str("sort"); + if (sort == null || !RosterSorts.Contains(sort)) { clean.Remove("sort"); }