6 forked calendar events on relay.edufeed.org (same pubkey+d under both 31922 and 31923) #67

Open
opened 2026-07-30 14:11:51 +00:00 by laoc · 4 comments
Owner

Follow-up to #65, which was preventive only — it stops the app creating new forks but repairs nothing existing. TestOER raised that there might be real forked pairs on production. There are: 6, measured.

What I measured

nak count on wss://relay.edufeed.org, bogus-kind control (kinds:[65535] -> 0) so COUNT is honoured:

kind 31922:  680
kind 31923: 2425

Then paginated both kinds by until and grouped by (pubkey, d):

kind 31922: fetched  679 / 680   (99.9%)
kind 31923: fetched 2314 / 2425  (95.4%)
FORKED PAIRS (same pubkey+d live under BOTH kinds): 6

6 is a lower bound, not a total — the 31923 scan reached 95.4% coverage. Events sharing a created_at second at a page boundary are where the remainder sits.

The 6

0a45c70742a7…  d=oerworldmap:bc4676cd-84af-4314-a3ac-cab8ee5ab146
   31922 ca=1784787408  'Kick-Off „Qualifizierung Studierender …“'
   31923 ca=1785413989  'PrimOER Kick-Off: Qualifizierung für die multiprofessionelle …'
4fa5d1c413e2…  d=a68eacf6-c380-4e27-adca-917dc662c32d
   31922 ca=1783441037  'Studientag Globales Lernen: Verbundenheit als Grundlage des Lebens'
   31923 ca=1783441176  (same title)
8287095e0898…  d=bildungsserver:40214   'KONFERENZ BILDUNG DIGITALISIERUNG 2027' / 'Konferenz Bildung Digitalisierung 2027'
8287095e0898…  d=bildungsserver:41316   'Festival der Illustration 2026' / 'Festival der Illustration Oldenburg 2026…'
8287095e0898…  d=bildungsserver:41374   '35. Kinderbuchmesse Lörracher LeseLust'  (same title)
8287095e0898…  d=bildungsserver:41376   'Zuversicht statt Krisenmodus…'          (same title)

These are NOT from the #65 code path

Worth stating plainly, because the obvious reading is "the toggle bug did this" and the evidence says otherwise.

The app mints its own d-tags as event-${Date.now()}-${Math.random().toString(36).substr(2,9)} (calendar-actions.svelte.js:68). None of the 6 has that shape — 4 are bildungsserver:<id>, 1 is oerworldmap:<uuid>, 1 is a bare UUID. All six carry importer-assigned identifiers, and 4 of 6 share a single pubkey.

So the cause is an importer republishing the same source record under a different kind on a later run — the same (pubkey, d) with a different date/time decision. #66 would never have prevented these, and fixing the app does not stop more appearing.

What this needs

  1. Decide which of each pair is canonical and delete the other with NIP-09. Four pairs have effectively identical titles, so it is a straight duplicate; two have genuinely different titles and need a look before choosing.
  2. Fix the importer's kind decision to be stable for a given source record, or have it delete the old-kind event when it changes its mind. Otherwise this regrows.
  3. Re-run the scan afterwards at full coverage to confirm 0.

Not doing any of the deletions without an explicit go-ahead — they are on a production relay and two of the pairs are not obvious duplicates.

Reproducing the scan

Group all 31922 and all 31923 by (pubkey, d-tag) and intersect. Two traps: nak req filters client-side so it can look right while the relay honoured nothing (use nak count for the totals and a bogus-kind control), and naive until pagination stalls on a page boundary where many events share one second — step until past it or you silently stop at ~84%.

Follow-up to #65, which was **preventive only** — it stops the app creating new forks but repairs nothing existing. TestOER raised that there might be real forked pairs on production. There are: **6**, measured. ## What I measured `nak count` on `wss://relay.edufeed.org`, bogus-kind control (`kinds:[65535]` -> **0**) so COUNT is honoured: ``` kind 31922: 680 kind 31923: 2425 ``` Then paginated both kinds by `until` and grouped by `(pubkey, d)`: ``` kind 31922: fetched 679 / 680 (99.9%) kind 31923: fetched 2314 / 2425 (95.4%) FORKED PAIRS (same pubkey+d live under BOTH kinds): 6 ``` **6 is a lower bound**, not a total — the 31923 scan reached 95.4% coverage. Events sharing a `created_at` second at a page boundary are where the remainder sits. ## The 6 ``` 0a45c70742a7… d=oerworldmap:bc4676cd-84af-4314-a3ac-cab8ee5ab146 31922 ca=1784787408 'Kick-Off „Qualifizierung Studierender …“' 31923 ca=1785413989 'PrimOER Kick-Off: Qualifizierung für die multiprofessionelle …' 4fa5d1c413e2… d=a68eacf6-c380-4e27-adca-917dc662c32d 31922 ca=1783441037 'Studientag Globales Lernen: Verbundenheit als Grundlage des Lebens' 31923 ca=1783441176 (same title) 8287095e0898… d=bildungsserver:40214 'KONFERENZ BILDUNG DIGITALISIERUNG 2027' / 'Konferenz Bildung Digitalisierung 2027' 8287095e0898… d=bildungsserver:41316 'Festival der Illustration 2026' / 'Festival der Illustration Oldenburg 2026…' 8287095e0898… d=bildungsserver:41374 '35. Kinderbuchmesse Lörracher LeseLust' (same title) 8287095e0898… d=bildungsserver:41376 'Zuversicht statt Krisenmodus…' (same title) ``` ## These are NOT from the #65 code path Worth stating plainly, because the obvious reading is "the toggle bug did this" and the evidence says otherwise. The app mints its own d-tags as `` event-${Date.now()}-${Math.random().toString(36).substr(2,9)} `` (`calendar-actions.svelte.js:68`). **None of the 6 has that shape** — 4 are `bildungsserver:<id>`, 1 is `oerworldmap:<uuid>`, 1 is a bare UUID. All six carry importer-assigned identifiers, and 4 of 6 share a single pubkey. So the cause is an **importer republishing the same source record under a different kind on a later run** — the same `(pubkey, d)` with a different date/time decision. #66 would never have prevented these, and fixing the app does not stop more appearing. ## What this needs 1. **Decide which of each pair is canonical** and delete the other with NIP-09. Four pairs have effectively identical titles, so it is a straight duplicate; two have genuinely different titles and need a look before choosing. 2. **Fix the importer's kind decision to be stable** for a given source record, or have it delete the old-kind event when it changes its mind. Otherwise this regrows. 3. Re-run the scan afterwards at full coverage to confirm 0. **Not doing any of the deletions without an explicit go-ahead** — they are on a production relay and two of the pairs are not obvious duplicates. ## Reproducing the scan Group all 31922 and all 31923 by `(pubkey, d-tag)` and intersect. Two traps: `nak req` filters client-side so it can look right while the relay honoured nothing (use `nak count` for the totals and a bogus-kind control), and naive `until` pagination stalls on a page boundary where many events share one second — step `until` past it or you silently stop at ~84%.
Author
Owner

Correction: 1 of the 6 IS from #65, and my d-tag-shape test could not have seen it

The reasoning in the issue body above is wrong on this point. TestOER found the flaw and I have verified it against the relay and the source myself.

Why the d-tag-shape test was invalid as a causal test. createEvent mints event-<ms>-<rand> — but updateEvent does not mint anything, it reads the d-tag off the event it replaces (calendar-actions.svelte.js:139). So d-tag shape tells you how an event was created, never how a fork was produced. A user editing an imported event in the app forks it while keeping the importer's bildungsserver:… or bare-UUID d-tag — exactly the case my test excluded by construction. I had read that line myself earlier in the same session and still built the inference on top of it.

The pair that is ours, re-queried directly and confirmed (bogus-#d control returns 0):

pubkey 4fa5d1c413e2…   d=a68eacf6-c380-4e27-adca-917dc662c32d

31922  ca 1783441037   start="2026-10-20"   (all-day)
       tags: d,title,start,D,summary,t,t,t,t,r        NO client tag
31923  ca 1783441176   start="1792479600"   (timed)
       tags: d,title,start,end,start_tzid,end_tzid,D,image,location,r,r,client
       ["client","Edufeed"]

139 seconds apart, all-day -> timed, and the later one carries this app's own NIP-89 client tag. It also gained precisely what a filled edit form produces: a unix start and an end, start_tzid/end_tzid, an image, a location.

Two supporting claims I checked in source rather than taking:

  • D tags are emitted only for kind 31923 (calendar.js:842, if (eventData.kind === 31923 && eventData.start)). The older 31922 carries a D tag, so it was not built by this app — consistent with imported-then-edited-here.
  • buildCalendarEventTags has exactly two callers in src/calendar-actions.svelte.js:74 (create) and :167 (update), excluding __tests__. So an Edufeed-tagged event on a foreign d-tag can only have come through updateEvent.

What this changes

count cause does #66 prevent recurrence?
bildungsserver: x4, oerworldmap: x1 5 importer republishing a source record under a different kind (~31d and 7d apart, no client tag) no
a68eacf6-… 1 this bug, a real user, in production yes

So "fixing the app does not stop more appearing" was wrong — it holds for the 5 importer pairs and not for the user-caused class. #66 does stop that class.

Remediation, revised

The user pair needs no production write from us. The pubkey belongs to a person who owns the key, and the app's own Delete affordance is available to the event owner (CalendarEventDetailView.svelte:196). The 31923 is the finished version — the 31922 has no end, location or image — so if a winner is picked it is the 31923, and the owner can retire the stale 31922 themselves.

The 5 importer pairs are unchanged and still need whoever owns the importer: a stable kind decision per source record, or a NIP-09 delete of the old-kind event when the decision changes.

Coverage, stated jointly

TestOER ran an independent scan and landed on the same 680 / 2314 / 6, hitting the same pagination stall (a dense created_at second returning +0 new, needing the cursor walked back a second). 95.4% of kind 31923 is a shared ceiling, not one run's limitation — two independent scans agreeing at the same coverage is not the same as coverage being complete. 6 remains a lower bound.

## Correction: 1 of the 6 IS from #65, and my d-tag-shape test could not have seen it **The reasoning in the issue body above is wrong on this point.** TestOER found the flaw and I have verified it against the relay and the source myself. **Why the d-tag-shape test was invalid as a causal test.** `createEvent` mints `event-<ms>-<rand>` — but `updateEvent` does **not** mint anything, it reads the d-tag off the event it replaces (`calendar-actions.svelte.js:139`). So d-tag shape tells you how an event was **created**, never how a fork was **produced**. A user editing an *imported* event in the app forks it while keeping the importer's `bildungsserver:…` or bare-UUID d-tag — exactly the case my test excluded by construction. I had read that line myself earlier in the same session and still built the inference on top of it. **The pair that is ours**, re-queried directly and confirmed (bogus-`#d` control returns 0): ``` pubkey 4fa5d1c413e2… d=a68eacf6-c380-4e27-adca-917dc662c32d 31922 ca 1783441037 start="2026-10-20" (all-day) tags: d,title,start,D,summary,t,t,t,t,r NO client tag 31923 ca 1783441176 start="1792479600" (timed) tags: d,title,start,end,start_tzid,end_tzid,D,image,location,r,r,client ["client","Edufeed"] ``` **139 seconds apart**, all-day -> timed, and the later one carries this app's own NIP-89 `client` tag. It also gained precisely what a filled edit form produces: a unix `start` *and* an `end`, `start_tzid`/`end_tzid`, an `image`, a `location`. Two supporting claims I checked in source rather than taking: - **`D` tags are emitted only for kind 31923** (`calendar.js:842`, `if (eventData.kind === 31923 && eventData.start)`). The older 31922 carries a `D` tag, so it was not built by this app — consistent with imported-then-edited-here. - **`buildCalendarEventTags` has exactly two callers in `src/`** — `calendar-actions.svelte.js:74` (create) and `:167` (update), excluding `__tests__`. So an `Edufeed`-tagged event on a foreign d-tag can only have come through `updateEvent`. ## What this changes | | count | cause | does #66 prevent recurrence? | |---|---|---|---| | `bildungsserver:` x4, `oerworldmap:` x1 | 5 | importer republishing a source record under a different kind (~31d and 7d apart, no `client` tag) | **no** | | `a68eacf6-…` | 1 | **this bug, a real user, in production** | **yes** | So **"fixing the app does not stop more appearing" was wrong** — it holds for the 5 importer pairs and not for the user-caused class. #66 does stop that class. ## Remediation, revised **The user pair needs no production write from us.** The pubkey belongs to a person who owns the key, and the app's own Delete affordance is available to the event owner (`CalendarEventDetailView.svelte:196`). The 31923 is the finished version — the 31922 has no `end`, `location` or `image` — so if a winner is picked it is the 31923, and the owner can retire the stale 31922 themselves. **The 5 importer pairs are unchanged** and still need whoever owns the importer: a stable kind decision per source record, or a NIP-09 delete of the old-kind event when the decision changes. ## Coverage, stated jointly TestOER ran an independent scan and landed on the same 680 / 2314 / 6, hitting the same pagination stall (a dense `created_at` second returning `+0 new`, needing the cursor walked back a second). **95.4% of kind 31923 is a shared ceiling, not one run's limitation** — two independent scans agreeing at the same coverage is not the same as coverage being complete. 6 remains a lower bound.
Author
Owner

Second correction: 6 is exact, not a lower bound

The "95.4% coverage, so 6 is a floor" caveat is now closed. TestOER closed it and I reproduced the decisive half independently, by a different query path.

The method that closes it: you never needed the missing 31923s. A fork requires a member of both kinds at one (pubkey, d), so you can enumerate coordinates from the 31922 side and query 31923 point-wise with #d. That is a completely different query path from the time-scan both of us originally ran, so it is not the earlier scan agreeing with itself.

My independent sweep, COUNT-driven bucketing (split a window when its COUNT exceeds 400, rather than when the returned batch looks full):

kind 31922: 679 fetched / COUNT 680
31922 coordinates: 678 across 15 authors  (+1 event with no d-tag)
coords holding >1 live 31922: 0
FORKS via complete-side #d sweep: 6
CONTROL bogus-#d sweep (3 authors): 0

0a45c70742a7…  oerworldmap:bc4676cd-…    client=none
4fa5d1c413e2…  a68eacf6-…                client=['Edufeed']   <- the one from this bug
8287095e0898…  bildungsserver:40214      client=none
8287095e0898…  bildungsserver:41316      client=none
8287095e0898…  bildungsserver:41374      client=none
8287095e0898…  bildungsserver:41376      client=none

Same six, same single client=Edufeed.

The argument that actually closes it is the author-side one, and it does not depend on 31922 being complete. The unreachable 31923s live in two dense seconds that are single-author, and that author has no 31922 events at all:

second 1777994778:  COUNT 611, REQ 500, distinct authors 1  (f6c14ab7add6…)
second 1777994779:  COUNT 655, REQ 500, distinct authors 1  (f6c14ab7add6…)
    f6c14ab7add6…  COUNT{kinds:[31922], authors:[f6c14ab7…]} = 0

No 31922 from that author anywhere on the relay, so those events cannot form a pair no matter how many of them stay unreachable. Verified directly.

One caveat on the other proof, from my own run

I could not reproduce "31922 is 100% complete". I got 679 of COUNT 680, and narrowing the one bad window shows the shortfall is not a cap — it is COUNT and REQ disagreeing at 1-2 event granularity:

[1785413382, 1785413383)   COUNT=2  REQ=1
[1785413383, 1785413384)   COUNT=1  REQ=0

Both windows are one second wide with counts far below any limit, so nothing is being truncated. Either COUNT is over-reporting (counting something the read path will not serve — a deleted or superseded event would do it) or REQ is withholding. I cannot tell which from outside the relay, and it is a small enough discrepancy that I am recording it rather than chasing it here.

It does not weaken the conclusion, because the author-side argument stands alone. But it is the reason I would state the result as "6 exact, on the author-side proof" rather than resting it on 31922 completeness — which is a claim my own scan does not support.

Also found, minor

aa404cd7… is a kind 31922 carrying no d tag at all — title Testtermin 201, author 67aea7eb…. An addressable event without a d is not addressable: it can never be replaced, and no a tag can reference it. Not a fork, not urgent, presumably a leftover test event. Independently confirmed; noting it here since this is where calendar-data cleanup is tracked.

And no coordinate holds more than one live 31922 (678 coords, 0 with >1), so there is no second class of duplication hiding in that kind.

## Second correction: **6 is exact**, not a lower bound The "95.4% coverage, so 6 is a floor" caveat is now closed. TestOER closed it and I reproduced the decisive half independently, by a different query path. **The method that closes it:** you never needed the missing 31923s. A fork requires a member of **both** kinds at one `(pubkey, d)`, so you can enumerate coordinates from the *31922* side and query 31923 point-wise with `#d`. That is a completely different query path from the time-scan both of us originally ran, so it is not the earlier scan agreeing with itself. My independent sweep, COUNT-driven bucketing (split a window when its COUNT exceeds 400, rather than when the returned batch looks full): ``` kind 31922: 679 fetched / COUNT 680 31922 coordinates: 678 across 15 authors (+1 event with no d-tag) coords holding >1 live 31922: 0 FORKS via complete-side #d sweep: 6 CONTROL bogus-#d sweep (3 authors): 0 0a45c70742a7… oerworldmap:bc4676cd-… client=none 4fa5d1c413e2… a68eacf6-… client=['Edufeed'] <- the one from this bug 8287095e0898… bildungsserver:40214 client=none 8287095e0898… bildungsserver:41316 client=none 8287095e0898… bildungsserver:41374 client=none 8287095e0898… bildungsserver:41376 client=none ``` Same six, same single `client=Edufeed`. **The argument that actually closes it** is the author-side one, and it does not depend on 31922 being complete. The unreachable 31923s live in two dense seconds that are **single-author**, and that author has no 31922 events at all: ``` second 1777994778: COUNT 611, REQ 500, distinct authors 1 (f6c14ab7add6…) second 1777994779: COUNT 655, REQ 500, distinct authors 1 (f6c14ab7add6…) f6c14ab7add6… COUNT{kinds:[31922], authors:[f6c14ab7…]} = 0 ``` No 31922 from that author anywhere on the relay, so those events cannot form a pair no matter how many of them stay unreachable. Verified directly. ## One caveat on the *other* proof, from my own run I could **not** reproduce "31922 is 100% complete". I got **679 of COUNT 680**, and narrowing the one bad window shows the shortfall is not a cap — it is **COUNT and REQ disagreeing at 1-2 event granularity**: ``` [1785413382, 1785413383) COUNT=2 REQ=1 [1785413383, 1785413384) COUNT=1 REQ=0 ``` Both windows are one second wide with counts far below any limit, so nothing is being truncated. Either COUNT is over-reporting (counting something the read path will not serve — a deleted or superseded event would do it) or REQ is withholding. **I cannot tell which from outside the relay**, and it is a small enough discrepancy that I am recording it rather than chasing it here. It does not weaken the conclusion, because the author-side argument stands alone. But it is the reason I would state the result as **"6 exact, on the author-side proof"** rather than resting it on 31922 completeness — which is a claim my own scan does not support. ## Also found, minor `aa404cd7…` is a **kind 31922 carrying no `d` tag at all** — title `Testtermin 201`, author `67aea7eb…`. An addressable event without a `d` is not addressable: it can never be replaced, and no `a` tag can reference it. Not a fork, not urgent, presumably a leftover test event. Independently confirmed; noting it here since this is where calendar-data cleanup is tracked. And **no coordinate holds more than one live 31922** (678 coords, 0 with >1), so there is no second class of duplication hiding in that kind.
Author
Owner

Retracting the "COUNT/REQ divergence" — and replacing it with the actual cause

TestOER was right that my framing was wrong, and right that until is inclusive. Both are my errors:

  • since and until are both inclusive (NIP-01: created_at >= since, created_at <= until). I described [X, X+1) windows that were really two seconds wide and overlapping. Confirmed empirically: {since: X, until: X} returns exactly the events at created_at == X. My bucketer also split as [lo,mid] + [mid,hi], double-querying the boundary second — harmless because we dedupe by id, but wasteful. Correct split is [lo,mid] + [mid+1,hi].
  • It is not a relay read-path defect. No event is being withheld and COUNT is not over-reporting.

But the divergence was real and reproducible on my side, 3/3 — and the cause is a genuine data finding.

$ echo '{"kinds":[31922],"since":1785413383,"until":1785413383}' | nak req wss://relay.edufeed.org
stdout: 0 bytes
stderr: {wss://relay.edufeed.org} bad signature on id::266f759c09dc99ee3c428e0397f0a1b87e432cd2334aa211f4a0e98c51483125

relay.edufeed.org is serving a kind-31922 event whose signature does not verify.

id      266f759c09dc99ee…
kind    31922   author 0a45c70742a7e726…   created_at 1785413383
d       oerworldmap:dd8fa528-f990-492a-b850-39d08592bbac
title   Konferenz Bildung Digitalisierung 2019:Next Practice – …
sig     128 chars (present, but invalid)

Re-scanned the whole kind accounting for it, with mid+1 splitting and a per-window check:

kind 31922:  COUNT = 680
             valid-signature events fetched = 679
             bad-signature events dropped   =   1
             679 + 1 = 680          unexplained windows: 0

Both of my anomalies were this one event. The unbounded query returning 499 rather than 500 was the relay serving 500 and nak dropping the bad one — I checked, and that query's stderr carries exactly one bad signature line. So my "the relay returns 499" explanation for the silent quarter-scan was wrong too; the cap is 500 and the shortfall was the same single event.

Why neither of our readers told us

  • nak verifies signatures and drops failures to stderr. My parser read stdout only, so a rejected event vanished with no error — a silent, plausible undercount.
  • A raw WebSocket reader does not verify at all, so it returns the event and counts it as ordinary. That is why TestOER measured 680/680 with zero divergent windows: their 680 includes an event that does not verify.

Neither number was wrong about the relay; they were measuring different things. 679 valid + 1 invalid = 680 served.

It does not change the fork count

The bad-signature event sits at a coordinate my complete-side sweep never saw, so it could have hidden a seventh pair. It does not:

kind 31922 at 0a45c707…/oerworldmap:dd8fa528-…   COUNT=1  REQ=1
kind 31923 at the same coordinate                COUNT=0  REQ=0
CONTROL bogus-d, same author                     0

No 31923 at that address, so it is not a fork. 6 remains exact.

What this adds to the cleanup list

Alongside the malformed no-d Testtermin 201, this relay now has a known invalid-signature event in the calendar data. Any client that verifies (most do) will silently ignore it, so it is effectively invisible to users while still occupying its coordinate and being counted by COUNT.

Whether the relay should have accepted it at all is a relay-side question, not an app one — NIP-01 puts signature validation on the relay before acceptance. I am raising that in the relay lane rather than here; this issue just records that the event exists and that it is not one of the six.

## Retracting the "COUNT/REQ divergence" — and replacing it with the actual cause TestOER was right that my framing was wrong, and right that `until` is inclusive. Both are my errors: - **`since` and `until` are both inclusive** (NIP-01: `created_at >= since`, `created_at <= until`). I described `[X, X+1)` windows that were really two seconds wide and overlapping. Confirmed empirically: `{since: X, until: X}` returns exactly the events at `created_at == X`. My bucketer also split as `[lo,mid] + [mid,hi]`, double-querying the boundary second — harmless because we dedupe by id, but wasteful. Correct split is `[lo,mid] + [mid+1,hi]`. - **It is not a relay read-path defect.** No event is being withheld and COUNT is not over-reporting. **But the divergence was real and reproducible on my side, 3/3 — and the cause is a genuine data finding.** ``` $ echo '{"kinds":[31922],"since":1785413383,"until":1785413383}' | nak req wss://relay.edufeed.org stdout: 0 bytes stderr: {wss://relay.edufeed.org} bad signature on id::266f759c09dc99ee3c428e0397f0a1b87e432cd2334aa211f4a0e98c51483125 ``` **`relay.edufeed.org` is serving a kind-31922 event whose signature does not verify.** ``` id 266f759c09dc99ee… kind 31922 author 0a45c70742a7e726… created_at 1785413383 d oerworldmap:dd8fa528-f990-492a-b850-39d08592bbac title Konferenz Bildung Digitalisierung 2019:Next Practice – … sig 128 chars (present, but invalid) ``` Re-scanned the whole kind accounting for it, with `mid+1` splitting and a per-window check: ``` kind 31922: COUNT = 680 valid-signature events fetched = 679 bad-signature events dropped = 1 679 + 1 = 680 unexplained windows: 0 ``` **Both of my anomalies were this one event.** The unbounded query returning 499 rather than 500 was the relay serving 500 and `nak` dropping the bad one — I checked, and that query's stderr carries exactly one `bad signature` line. So my "the relay returns 499" explanation for the silent quarter-scan was wrong too; the cap is 500 and the shortfall was the same single event. ## Why neither of our readers told us - **`nak` verifies signatures and drops failures to stderr.** My parser read stdout only, so a rejected event vanished with no error — a silent, plausible undercount. - **A raw WebSocket reader does not verify at all**, so it returns the event and counts it as ordinary. That is why TestOER measured 680/680 with zero divergent windows: their 680 *includes* an event that does not verify. Neither number was wrong about the relay; they were measuring different things. 679 valid + 1 invalid = 680 served. ## It does not change the fork count The bad-signature event sits at a coordinate my complete-side sweep never saw, so it could have hidden a seventh pair. It does not: ``` kind 31922 at 0a45c707…/oerworldmap:dd8fa528-… COUNT=1 REQ=1 kind 31923 at the same coordinate COUNT=0 REQ=0 CONTROL bogus-d, same author 0 ``` No 31923 at that address, so it is not a fork. **6 remains exact.** ## What this adds to the cleanup list Alongside the malformed no-`d` `Testtermin 201`, this relay now has a known **invalid-signature event** in the calendar data. Any client that verifies (most do) will silently ignore it, so it is effectively invisible to users while still occupying its coordinate and being counted by COUNT. Whether the relay should have accepted it at all is a relay-side question, not an app one — NIP-01 puts signature validation on the relay before acceptance. I am raising that in the relay lane rather than here; this issue just records that the event exists and that it is *not* one of the six.
Author
Owner

Correction: that signature is valid. My "bad signature" reading was wrong.

Retracting the previous comment's framing. TestOER caught it and I reproduced the disproof by construction rather than taking it.

The event has a raw U+001E (RECORD SEPARATOR) in its content, and the two serializations disagree:

content: 'Unter der Überschrift \x1e„Next Practice – Bildungsinnovationen …'
control chars present: U+000A, U+001E

id recomputed, escaping U+001E as \u001e  ->  266f759c09dc99ee…   *** MATCHES THE SERVED ID
id recomputed, raw 0x1E byte              ->  8523263f6f7c7667…

The second hash is exactly the id nak verify reports as "expected". So the event's id and signature are internally consistent under JSON escaping; nak/go-nostr computes a different id and therefore reports "bad signature".

The spec genuinely is ambiguous here. NIP-01 lists exactly seven characters that must be escaped — \n \" \\ \r \t \b \f — and says "all other characters must be included verbatim". RFC 8259 §7 requires every character below U+0020 to be escaped. For an unlisted control character the two rules contradict, and each implementation picked a side.

One data point I can add: this relay is strfry, not a Go relay.

software: git+https://github.com/hoytech/strfry.git
version : 1.1.0-81-g1461e6b

strfry validates signatures on ingest and accepted this event, so its serializer escapes the character too. That makes it two independent implementations (nostr-tools, strfry) escaping, and go-nostr the outlier — which is worth knowing before anyone decides the JS side is the one that is wrong.

What this changes for this issue

  • Nothing about the fork count. Still 6 exact. There is no 31923 at that coordinate, checked directly with a bogus-#d control.
  • The relay did its job. I am not filing anything in the relay lane; the previous comment's suggestion to do so was based on my wrong reading.
  • It is an upstream data-quality problem, and it lands on an owner already named here. The pubkey 0a45c707… is the oerworldmap importer — the same author as one of the six forked pairs. U+001E is almost certainly a stray field separator from the source record that was never meant to be in the text, so the importer should strip or replace control characters before publishing.

Why it is still worth recording

These events are invisible to Go-based clients and tooling. Anything on go-nostr computes a different id and drops the event as unverifiable — which is exactly what happened to my scan, silently, because nak reports the rejection on stderr while my parser read stdout only.

Scope, measured across every fetchable calendar event: 1 event of 2994 carries an unlisted control character (kind 31922: 1 of 680; kind 31923: 0 of 2314). edufeed-app is on nostr-tools/applesauce, so the app renders it fine — the asymmetry only bites at the Go boundary.

## Correction: that signature is **valid**. My "bad signature" reading was wrong. Retracting the previous comment's framing. TestOER caught it and I reproduced the disproof by construction rather than taking it. **The event has a raw U+001E (RECORD SEPARATOR) in its content**, and the two serializations disagree: ``` content: 'Unter der Überschrift \x1e„Next Practice – Bildungsinnovationen …' control chars present: U+000A, U+001E id recomputed, escaping U+001E as \u001e -> 266f759c09dc99ee… *** MATCHES THE SERVED ID id recomputed, raw 0x1E byte -> 8523263f6f7c7667… ``` The second hash is exactly the id `nak verify` reports as "expected". So the event's `id` and signature are internally consistent under JSON escaping; `nak`/go-nostr computes a different id and therefore reports "bad signature". **The spec genuinely is ambiguous here.** NIP-01 lists exactly seven characters that must be escaped — `\n \" \\ \r \t \b \f` — and says "all other characters must be included verbatim". RFC 8259 §7 requires every character below U+0020 to be escaped. For an *unlisted* control character the two rules contradict, and each implementation picked a side. **One data point I can add: this relay is strfry**, not a Go relay. ``` software: git+https://github.com/hoytech/strfry.git version : 1.1.0-81-g1461e6b ``` strfry validates signatures on ingest and accepted this event, so its serializer escapes the character too. That makes it **two independent implementations (nostr-tools, strfry) escaping, and go-nostr the outlier** — which is worth knowing before anyone decides the JS side is the one that is wrong. ## What this changes for this issue - **Nothing about the fork count.** Still 6 exact. There is no 31923 at that coordinate, checked directly with a bogus-`#d` control. - **The relay did its job.** I am not filing anything in the relay lane; the previous comment's suggestion to do so was based on my wrong reading. - **It is an upstream data-quality problem, and it lands on an owner already named here.** The pubkey `0a45c707…` is the **oerworldmap importer** — the same author as one of the six forked pairs. U+001E is almost certainly a stray field separator from the source record that was never meant to be in the text, so the importer should strip or replace control characters before publishing. ## Why it is still worth recording **These events are invisible to Go-based clients and tooling.** Anything on go-nostr computes a different id and drops the event as unverifiable — which is exactly what happened to my scan, silently, because `nak` reports the rejection on stderr while my parser read stdout only. Scope, measured across every fetchable calendar event: **1 event of 2994** carries an unlisted control character (kind 31922: 1 of 680; kind 31923: 0 of 2314). edufeed-app is on nostr-tools/applesauce, so the app renders it fine — the asymmetry only bites at the Go boundary.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
edufeed/edufeed-app#67
No description provided.