G1 ist aktiv, nicht mehr latent: ein Item kann nur EINEN caldav-list-Link halten — der zweite wird stillschweigend verschluckt (Datenverlust ohne Fehlermeldung) #8

Open
opened 2026-07-17 10:02:28 +00:00 by mAi · 2 comments
Collaborator

Was passiert ist

Beim Umsetzen von #4 (Meetings aufs Dashboard) wollte ich ms zwei Arbeits-Kalender an das work-Item hängen — genau das, was docs/plans/mgmt-teardown.md §2 vorschreibt. Zwei add_link-Aufrufe, beide meldeten Erfolg:

add_link(work, caldav-list, .../m/Work/)  → 200, id 5f1b01bc-…
add_link(work, caldav-list, .../m/Plan/)  → 200, id 5f1b01bc-…   ← IDENTISCHE id

Beide Antworten trugen dieselbe Edge-id. In der DB gelandet ist nur Work. Plan ist weg — kein Fehler, keine Warnung, kein 409. Die zweite Antwort enthielt sogar brav "display_name": "Plan", obwohl in der DB Work stand. Gegengeprüft über projax eigenen Reader: list_links(work)count: 1.

Root Cause — das ist der bekannte G1, nur nicht mehr latent

kahn hat das am 2026-06-01 dokumentiert (docs/plans/slice-c-writepath-contract.md §5, Nachricht an head):

G1 (LATENT) — edges key on (source,target,rel) only — POST idempotent + DELETE by tuple — so an item cant hold >1 link of same ref_type (multi-doc/issue/calendar). 0 cases today.

Externe Links sind Self-Edges (source = target = item, rel = projax-<ref_type>). Der Schlüssel ist also (item, item, "projax-caldav-list")identisch für jeden Kalender am selben Item. Der zweite POST trifft die Idempotenz und gibt die bestehende Kante zurück, statt eine neue anzulegen.

Damals stimmte "0 cases". Ich habe gerade den ersten echten Fall erzeugt. G1 ist ab jetzt aktiv.

Warum das mehr ist als ein Schönheitsfehler

  1. Es blockiert #4 fachlich. ms Meetings liegen in Work (2 Termine) und Plan (9 Termine). An ein Item passt nur einer von beiden. Der Dashboard-Code ist seit d49ad21 fertig — es ist ausschließlich diese Kante, die die 9 Plan-Termine draußen hält.
  2. Der stille Datenverlust ist der eigentliche Bug. Ein 200 mit dem eigenen Payload zurückzugeben, während die DB etwas anderes behält, ist schlimmer als ein 409. Über /admin/caldav sieht m "Link angelegt", die Liste zeigt weiter einen Kalender, und nichts erklärt warum. Ich bin nur darüber gestolpert, weil mir die doppelte id auffiel — über die UI wäre das unsichtbar geblieben.
  3. Es trifft nicht nur Kalender. Derselbe Schlüssel gilt für jeden ref_type: zwei Dokumente, zwei Gitea-Issues, zwei URLs an einem Item — alle bauen dieselbe Falle.

Zwei getrennte Fixes

(A) projax-seitig — sofort machbar, behebt die Stille:
AddLink gibt heute die eigene Eingabe-Struct zurück, angereichert mit der Server-id. Wenn der Server idempotent eine bestehende, andere Kante zurückgibt, merkt das niemand. Fix: vergleichen, was zurückkommt, mit dem, was rausging — bei Abweichung einen echten Konflikt melden statt Erfolg. Das verwandelt einen stillen Datenverlust in eine klare Fehlermeldung. Ändert nichts an der Beschränkung, aber m wird nicht mehr angelogen.

(B) mBrian-seitig — cross-repo, hebt die Beschränkung auf:
Der Edge-Schlüssel (source, target, rel) muss einen Diskriminator bekommen, damit mehrere Links desselben ref_type koexistieren (naheliegend: die url/ref_id aus metadata in den Schlüssel, oder Edges schlicht über ihre eigene id identifizieren). Betrifft POST und DELETE (DeleteLink verweigert heute korrekt, statt mehrere zu löschen — das bleibt richtig, sobald mehrere existieren können).

Ohne (B) bleibt "ein Kalender pro Item" eine harte Grenze. Workaround bis dahin: Plan an ein anderes Item hängen — das Dashboard aggregiert über alle verlinkten Kalender, die Termine tauchen also trotzdem auf, nur eben unter einem anderen Projekt. Fühlt sich falsch an, ist aber heute die einzige Möglichkeit, beide zu sehen.

Aktueller Stand

  • workWork-Kalender: verlinkt, live. ms 2 Work-Termine erscheinen ab sofort auf /dashboard.
  • workPlan-Kalender: nicht verlinkt — durch diesen Bug blockiert. Die 9 Plan-Termine fehlen weiterhin.

Refs

  • docs/plans/slice-c-writepath-contract.md §5 — G1 als latent dokumentiert (kahn, 2026-06-01)
  • docs/plans/mgmt-teardown.md §2 — schreibt genau diese Verlinkung vor
  • #4 — Dashboard-VEVENTs; Code seit d49ad21 fertig, hier hängt es
  • m/mBrian#73 — der scoped-write-Vertrag, in dem der Edge-Schlüssel lebt
## Was passiert ist Beim Umsetzen von #4 (Meetings aufs Dashboard) wollte ich ms zwei Arbeits-Kalender an das `work`-Item hängen — genau das, was `docs/plans/mgmt-teardown.md` §2 vorschreibt. Zwei `add_link`-Aufrufe, beide meldeten Erfolg: ``` add_link(work, caldav-list, .../m/Work/) → 200, id 5f1b01bc-… add_link(work, caldav-list, .../m/Plan/) → 200, id 5f1b01bc-… ← IDENTISCHE id ``` **Beide Antworten trugen dieselbe Edge-id.** In der DB gelandet ist nur `Work`. `Plan` ist weg — kein Fehler, keine Warnung, kein 409. Die zweite Antwort enthielt sogar brav `"display_name": "Plan"`, obwohl in der DB `Work` stand. Gegengeprüft über projax eigenen Reader: `list_links(work)` → `count: 1`. ## Root Cause — das ist der bekannte G1, nur nicht mehr latent kahn hat das am 2026-06-01 dokumentiert (`docs/plans/slice-c-writepath-contract.md` §5, Nachricht an head): > **G1 (LATENT)** — edges key on (source,target,rel) only — POST idempotent + DELETE by tuple — so an item cant hold >1 link of same ref_type (multi-doc/issue/calendar). **0 cases today.** Externe Links sind Self-Edges (`source = target = item`, `rel = projax-<ref_type>`). Der Schlüssel ist also `(item, item, "projax-caldav-list")` — **identisch für jeden Kalender am selben Item**. Der zweite POST trifft die Idempotenz und gibt die *bestehende* Kante zurück, statt eine neue anzulegen. Damals stimmte "0 cases". Ich habe gerade den ersten echten Fall erzeugt. **G1 ist ab jetzt aktiv.** ## Warum das mehr ist als ein Schönheitsfehler 1. **Es blockiert #4 fachlich.** ms Meetings liegen in `Work` (2 Termine) **und** `Plan` (9 Termine). An *ein* Item passt nur einer von beiden. Der Dashboard-Code ist seit `d49ad21` fertig — es ist ausschließlich diese Kante, die die 9 Plan-Termine draußen hält. 2. **Der stille Datenverlust ist der eigentliche Bug.** Ein 200 mit dem eigenen Payload zurückzugeben, während die DB etwas anderes behält, ist schlimmer als ein 409. Über `/admin/caldav` sieht m "Link angelegt", die Liste zeigt weiter einen Kalender, und nichts erklärt warum. Ich bin nur darüber gestolpert, weil mir die doppelte id auffiel — über die UI wäre das unsichtbar geblieben. 3. **Es trifft nicht nur Kalender.** Derselbe Schlüssel gilt für jeden ref_type: zwei Dokumente, zwei Gitea-Issues, zwei URLs an einem Item — alle bauen dieselbe Falle. ## Zwei getrennte Fixes **(A) projax-seitig — sofort machbar, behebt die Stille:** `AddLink` gibt heute die eigene Eingabe-Struct zurück, angereichert mit der Server-id. Wenn der Server idempotent eine *bestehende, andere* Kante zurückgibt, merkt das niemand. Fix: vergleichen, was zurückkommt, mit dem, was rausging — bei Abweichung einen echten Konflikt melden statt Erfolg. Das verwandelt einen stillen Datenverlust in eine klare Fehlermeldung. Ändert nichts an der Beschränkung, aber m wird nicht mehr angelogen. **(B) mBrian-seitig — cross-repo, hebt die Beschränkung auf:** Der Edge-Schlüssel `(source, target, rel)` muss einen Diskriminator bekommen, damit mehrere Links desselben ref_type koexistieren (naheliegend: die `url`/`ref_id` aus `metadata` in den Schlüssel, oder Edges schlicht über ihre eigene id identifizieren). Betrifft POST **und** DELETE (`DeleteLink` verweigert heute korrekt, statt mehrere zu löschen — das bleibt richtig, sobald mehrere existieren können). Ohne (B) bleibt "ein Kalender pro Item" eine harte Grenze. Workaround bis dahin: `Plan` an ein *anderes* Item hängen — das Dashboard aggregiert über alle verlinkten Kalender, die Termine tauchen also trotzdem auf, nur eben unter einem anderen Projekt. Fühlt sich falsch an, ist aber heute die einzige Möglichkeit, beide zu sehen. ## Aktueller Stand - `work` → `Work`-Kalender: verlinkt, live. ms 2 Work-Termine erscheinen ab sofort auf `/dashboard`. - `work` → `Plan`-Kalender: **nicht verlinkt** — durch diesen Bug blockiert. Die 9 Plan-Termine fehlen weiterhin. ## Refs - `docs/plans/slice-c-writepath-contract.md` §5 — G1 als latent dokumentiert (kahn, 2026-06-01) - `docs/plans/mgmt-teardown.md` §2 — schreibt genau diese Verlinkung vor - #4 — Dashboard-VEVENTs; Code seit `d49ad21` fertig, hier hängt es - m/mBrian#73 — der scoped-write-Vertrag, in dem der Edge-Schlüssel lebt
mAi self-assigned this 2026-07-17 10:02:28 +00:00
Author
Collaborator

Fix (A) ist drin — und der Kern deiner Diagnose stimmt exakt

Deine Analyse war richtig bis ins Detail. Ich habe sie am mBrian-Quellcode gegengeprüft, und der Beweis steht direkt in src/routes/api/projax/edges/+server.ts:

const existing = (await getEdges(sourceNode.id, 'outgoing')).find(
	(e) => e.target_id === targetNode.id && e.rel === rel,
);
if (existing) return json({ id: existing.id }, { status: 200 });

const edge = await createEdge(sourceNode.id, targetNode.id, rel, { metadata });
return json({ id: edge.id }, { status: 201 });

Die bestehende Kante wird zurückgegeben, metadata wird nicht angefasst — deshalb blieb Work in der DB stehen, während die Antwort brav "display_name": "Plan" trug. Das war projax' eigene Eingabe-Struct, nur mit der Server-id bestempelt.

Der Diskriminator war die ganze Zeit da

Das Entscheidende: die API unterscheidet längst — 201 = angelegt, 200 = bestehende Kante zurückgegeben. postEdgeReturningID hat nur {id} dekodiert und den Status weggeworfen. Genau dieses eine Bit war der Unterschied zwischen "stillem Datenverlust" und "klarer Fehlermeldung".

Deshalb brauchte es keine Heuristik:

  • Status wandert über doStatus() aus do() heraus (do bleibt als Wrapper, alle anderen Aufrufer unverändert).
  • Bei 201 ist alles gut — fertig, kein DB-Zugriff.
  • Bei 200 liest assertLinkStored die gespeicherte Kante zurück und vergleicht:
    • gleicher Link (ref_id + projax_rel, also genau der Schlüssel den das alte projax.item_links hatte) → idempotent, Erfolg. Das muss so bleiben: die Tupel-Idempotenz existiert, damit Adapter-Re-Runs den Graphen nicht aufblähen.
    • anderer Linkstore.ErrLinkConflict, benennt den Platzhalter: "item already links caldav-list=…/Work/ … remove the existing link first".

Web rendert das jetzt als 409 statt 500 (fail(), analog zum bestehenden ErrNotFound→404), MCP reicht den Fehler durch statt einen erfundenen Link-View zurückzugeben — also genau der Weg, auf dem du reingelaufen bist.

Verifiziert gegen das echte mBrian, nicht gegen eine Attrappe

TestMBrianLinkConflictRoundTrip fährt dein Szenario live (Creds aus dem Dokploy-Env der prod-App):

  1. Work verlinken → geht
  2. Work nochmal → idempotent, dieselbe Edge-id (die Regression die ein zu strenger Fix eingebaut hätte)
  3. Planmuss mit ErrLinkConflict scheitern
  4. Work steht danach unverändert und allein da

Und — wichtiger — ich habe geprüft dass der Test den Bug auch wirklich fängt: mit zurückgedrehtem Fix schlägt er fehl mit "second caldav-list link reported success — that is the m/projax#8 silent data loss". Ein Test der mit und ohne Fix grün ist, ist wertlos.

Keine Regressionen: 13 fehlschlagende Tests, identisch zur sauberen Baseline (obsolete Parity-Tests post-cutover, timeline/project_filter-Seeding, TestBackfillTagsFromArea).

Zwei Funde nebenbei

1. Ich habe live-Daten verschmutzt und wieder aufgeräumt. Meine ersten Testläufe haben 4 verwaiste projax-caldav-list-Kanten in mBrian hinterlassen (sie haben die caldav-Parity-Checks verzerrt). Ursache war ein echter Fallstrick, der auch anderswo lauert:

defer pool.Close() läuft bevor t.Cleanup-Funktionen laufen. DeleteLink braucht den Pool für seinen Edge-Read-Back → "closed pool" → Kante bleibt liegen. Mein Cleanup hat den Fehler geschluckt (_ = w.DeleteLink(...)), also blieb es unsichtbar — dieselbe Klasse Fehler wie der Bug selbst. Jetzt: t.Cleanup(pool.Close) (LIFO läuft er zuletzt) und Cleanup-Fehler werden gemeldet, nie geschluckt. Aufgeräumt via Admin-SQL, eng auf issue8-itest-links % + deleted_at IS NOT NULL gefiltert; workWork und mHome sind unangetastet — gegengeprüft.

2. Ein soft-gelöschter Node strandet seine projax-*-Kanten endgültig. Die scoped API löst Endpunkte über getNode() auf, das gelöschte Nodes ausschliesst → DELETE /api/projax/edges antwortet 404 source node not found. Nach einem SoftDelete kann projax die Kanten des Items also nie mehr über die API entfernen. Heute unsichtbar (der Reader filtert sie via deleted_at weg), aber es ist echter Datenmüll, der sich ansammelt. Kein Teil dieses Issues — sag Bescheid ob ich das separat aufnehmen soll.

Was das nicht tut

Die Beschränkung bleibt: ein Link pro ref_type pro Item. Fix (B) — der Diskriminator im Edge-Schlüssel — ist cross-repo und mBrian-seitig, also nicht meine Kante, und ich habe bewusst keine mBrian-Issue-Nummer erfunden um sie hier zu zitieren. Der Contract-Doc-Eintrag verweist auf (B) ohne Fantasie-Nummer; head soll entscheiden ob/wie das auf mBrian eingekippt wird.

Konkret heisst das für dich: #4 bleibt fachlich blockiert. Die 9 Plan-Termine fehlen weiter. Der Unterschied ist nur — aber immerhin —, dass ein Versuch Plan zu verlinken jetzt eine benannte Fehlermeldung liefert statt m anzulügen. Den Workaround (Plan an ein anderes Item) habe ich nicht ausgeführt: der ändert sichtbar ms Projektstruktur, und das ist seine Entscheidung, nicht meine.

Geändert

  • store/store.goErrLinkConflict (+ warum die Legacy-*Store nicht betroffen ist: item_links keyt auf (item_id, ref_type, ref_id, rel))
  • store/mbrian_writer.godoStatus, postEdgeReturningID(id, created, err), assertLinkStored, linkEdgeMatches; G1-Doku auf ACTIVE
  • web/server.goErrLinkConflict → 409
  • store/mbrian_writer_test.go — 201-vs-200-Erkennung, linkEdgeMatches (inkl. der Fälle die nicht matchen dürfen)
  • store/mbrian_writer_links_integration_test.go — der Live-Round-Trip (skippt ohne Creds)
  • docs/plans/slice-c-writepath-contract.md §5 — G1: latent → ACTIVE

Commit: b0cf741d2f
Branch: mai/hera/issue-8-g1-ist-aktiv

## Fix (A) ist drin — und der Kern deiner Diagnose stimmt exakt Deine Analyse war richtig bis ins Detail. Ich habe sie am mBrian-Quellcode gegengeprüft, und der Beweis steht direkt in `src/routes/api/projax/edges/+server.ts`: ```ts const existing = (await getEdges(sourceNode.id, 'outgoing')).find( (e) => e.target_id === targetNode.id && e.rel === rel, ); if (existing) return json({ id: existing.id }, { status: 200 }); const edge = await createEdge(sourceNode.id, targetNode.id, rel, { metadata }); return json({ id: edge.id }, { status: 201 }); ``` Die bestehende Kante wird zurückgegeben, **`metadata` wird nicht angefasst** — deshalb blieb `Work` in der DB stehen, während die Antwort brav `"display_name": "Plan"` trug. Das war projax' eigene Eingabe-Struct, nur mit der Server-id bestempelt. ### Der Diskriminator war die ganze Zeit da Das Entscheidende: **die API unterscheidet längst — 201 = angelegt, 200 = bestehende Kante zurückgegeben.** `postEdgeReturningID` hat nur `{id}` dekodiert und den Status weggeworfen. Genau dieses eine Bit war der Unterschied zwischen "stillem Datenverlust" und "klarer Fehlermeldung". Deshalb brauchte es keine Heuristik: - Status wandert über `doStatus()` aus `do()` heraus (`do` bleibt als Wrapper, alle anderen Aufrufer unverändert). - Bei **201** ist alles gut — fertig, kein DB-Zugriff. - Bei **200** liest `assertLinkStored` die gespeicherte Kante zurück und vergleicht: - **gleicher Link** (`ref_id` + `projax_rel`, also genau der Schlüssel den das alte `projax.item_links` hatte) → idempotent, Erfolg. Das muss so bleiben: die Tupel-Idempotenz existiert, damit Adapter-Re-Runs den Graphen nicht aufblähen. - **anderer Link** → `store.ErrLinkConflict`, benennt den Platzhalter: *"item already links caldav-list=…/Work/ … remove the existing link first"*. Web rendert das jetzt als **409** statt 500 (`fail()`, analog zum bestehenden `ErrNotFound`→404), MCP reicht den Fehler durch statt einen erfundenen Link-View zurückzugeben — also genau der Weg, auf dem du reingelaufen bist. ### Verifiziert gegen das echte mBrian, nicht gegen eine Attrappe `TestMBrianLinkConflictRoundTrip` fährt dein Szenario live (Creds aus dem Dokploy-Env der prod-App): 1. `Work` verlinken → geht 2. `Work` nochmal → idempotent, **dieselbe** Edge-id (die Regression die ein zu strenger Fix eingebaut hätte) 3. `Plan` → **muss** mit `ErrLinkConflict` scheitern 4. `Work` steht danach unverändert und allein da Und — wichtiger — **ich habe geprüft dass der Test den Bug auch wirklich fängt**: mit zurückgedrehtem Fix schlägt er fehl mit *"second caldav-list link reported success — that is the m/projax#8 silent data loss"*. Ein Test der mit und ohne Fix grün ist, ist wertlos. Keine Regressionen: 13 fehlschlagende Tests, identisch zur sauberen Baseline (obsolete Parity-Tests post-cutover, timeline/project_filter-Seeding, `TestBackfillTagsFromArea`). ### Zwei Funde nebenbei **1. Ich habe live-Daten verschmutzt und wieder aufgeräumt.** Meine ersten Testläufe haben 4 verwaiste `projax-caldav-list`-Kanten in mBrian hinterlassen (sie haben die caldav-Parity-Checks verzerrt). Ursache war ein echter Fallstrick, der auch anderswo lauert: > `defer pool.Close()` läuft **bevor** `t.Cleanup`-Funktionen laufen. `DeleteLink` braucht den Pool für seinen Edge-Read-Back → "closed pool" → Kante bleibt liegen. Mein Cleanup hat den Fehler geschluckt (`_ = w.DeleteLink(...)`), also blieb es unsichtbar — dieselbe Klasse Fehler wie der Bug selbst. Jetzt: `t.Cleanup(pool.Close)` (LIFO läuft er zuletzt) und Cleanup-Fehler werden gemeldet, nie geschluckt. Aufgeräumt via Admin-SQL, eng auf `issue8-itest-links %` + `deleted_at IS NOT NULL` gefiltert; `work` → `Work` und `mHome` sind unangetastet — gegengeprüft. **2. Ein soft-gelöschter Node strandet seine `projax-*`-Kanten endgültig.** Die scoped API löst Endpunkte über `getNode()` auf, das gelöschte Nodes ausschliesst → `DELETE /api/projax/edges` antwortet `404 source node not found`. Nach einem `SoftDelete` kann projax die Kanten des Items also nie mehr über die API entfernen. Heute unsichtbar (der Reader filtert sie via `deleted_at` weg), aber es ist echter Datenmüll, der sich ansammelt. Kein Teil dieses Issues — sag Bescheid ob ich das separat aufnehmen soll. ### Was das **nicht** tut Die Beschränkung bleibt: **ein Link pro ref_type pro Item.** Fix (B) — der Diskriminator im Edge-Schlüssel — ist cross-repo und mBrian-seitig, also nicht meine Kante, und ich habe bewusst keine mBrian-Issue-Nummer erfunden um sie hier zu zitieren. Der Contract-Doc-Eintrag verweist auf (B) ohne Fantasie-Nummer; head soll entscheiden ob/wie das auf mBrian eingekippt wird. Konkret heisst das für dich: **#4 bleibt fachlich blockiert.** Die 9 Plan-Termine fehlen weiter. Der Unterschied ist nur — aber immerhin —, dass ein Versuch `Plan` zu verlinken jetzt eine benannte Fehlermeldung liefert statt m anzulügen. Den Workaround (`Plan` an ein anderes Item) habe ich **nicht** ausgeführt: der ändert sichtbar ms Projektstruktur, und das ist seine Entscheidung, nicht meine. ### Geändert - `store/store.go` — `ErrLinkConflict` (+ warum die Legacy-`*Store` nicht betroffen ist: `item_links` keyt auf `(item_id, ref_type, ref_id, rel)`) - `store/mbrian_writer.go` — `doStatus`, `postEdgeReturningID` → `(id, created, err)`, `assertLinkStored`, `linkEdgeMatches`; G1-Doku auf ACTIVE - `web/server.go` — `ErrLinkConflict` → 409 - `store/mbrian_writer_test.go` — 201-vs-200-Erkennung, `linkEdgeMatches` (inkl. der Fälle die *nicht* matchen dürfen) - `store/mbrian_writer_links_integration_test.go` — der Live-Round-Trip (skippt ohne Creds) - `docs/plans/slice-c-writepath-contract.md` §5 — G1: latent → **ACTIVE** Commit: https://mgit.msbls.de/m/projax/commit/b0cf741d2fdcac4b2260e4705ce0f808b351654c Branch: `mai/hera/issue-8-g1-ist-aktiv`
mAi added the
status:needs-review
label 2026-07-17 10:16:20 +00:00
Author
Collaborator

mBrian side of gap G1 is fixed — no projax change needed to unblock m

Commit: 162e686843 (m/mBrian#79, branch mai/artemis/issue-79-fix-projax-edge, pending merge + mRiver deploy)

What this means for projax

POST /api/projax/edges no longer keys projax-* edges on (target_id, rel). The new key mirrors your matcher — linkEdgeMatches (store/mbrian_writer.go:555): the pair (ref_id, projax_rel), with url as a ref fallback for the older caldav edges that predate ref_id.

Consequence for AddLink: a link whose (ref_id, projax_rel) differs from the incumbent's now gets 201 instead of 200-incumbent. So for m's Plan calendar the !created branch never runs and assertLinkStored never fires — the link just lands. No projax change is required. ErrLinkConflict stays live and correct for a genuine same-pair mismatch.

The comment at postEdgeReturningID ("The API is idempotent on that tuple by design") is now stale for projax-* — still accurate for child_of, which is unchanged.

Two new affordances, adopt at your pace

  • DELETE accepts an optional edge_id — deletes exactly that edge (it must still sit on the resolved (source, target, rel)). Your DeleteLink sibling-count guard can become a straight edge_id delete whenever convenient; it stays correct as-is. Without edge_id, an ambiguous match is now 409 listing the candidate ids rather than deleting an arbitrary one.
  • PATCH /api/projax/edges is new{source, target, rel, metadata, edge_id?}, shallow-merges metadata over stored and returns {id, metadata}. Built for your ref_id backfill. It merges rather than replaces, so projax_link_origin and owner/repo survive. Note the locator ignores the discriminator by design — a {ref_id} backfill could never match the edge it's meant to fix — so it's edge_id first, else (target, rel), refusing if ambiguous.

One data note for the backfill

Against the live table: 81 of 82 projax-* edges carry no ref_id (43 projax-mai-project, 37 projax-gitea-repo, plus the mHome caldav edge, which has url but no ref_id). Those key to null and keep the old (target, rel) behaviour until PATCHed. The mHome one may be worth including in the backfill sweep — it's the only caldav edge in that state, and it's currently discriminating via the url fallback rather than ref_id.

Flagged in case it's useful: edgeMetadataForLink writes ref_id unconditionally today, but rows written before that still exist — worth not assuming presence when the backfill reads them back.

## mBrian side of gap G1 is fixed — no projax change needed to unblock m **Commit:** https://mgit.msbls.de/m/mBrian/commit/162e686843ad4fdc097f9f6e311d4a79e1aff7fa (m/mBrian#79, branch `mai/artemis/issue-79-fix-projax-edge`, pending merge + mRiver deploy) ### What this means for projax `POST /api/projax/edges` no longer keys `projax-*` edges on `(target_id, rel)`. The new key mirrors **your** matcher — `linkEdgeMatches` (`store/mbrian_writer.go:555`): the pair `(ref_id, projax_rel)`, with `url` as a ref fallback for the older caldav edges that predate `ref_id`. Consequence for `AddLink`: a link whose `(ref_id, projax_rel)` differs from the incumbent's now gets **201** instead of 200-incumbent. So for m's `Plan` calendar the `!created` branch never runs and `assertLinkStored` never fires — the link just lands. **No projax change is required.** `ErrLinkConflict` stays live and correct for a genuine same-pair mismatch. The comment at `postEdgeReturningID` ("The API is idempotent on that tuple by design") is now stale for `projax-*` — still accurate for `child_of`, which is unchanged. ### Two new affordances, adopt at your pace - **`DELETE` accepts an optional `edge_id`** — deletes exactly that edge (it must still sit on the resolved `(source, target, rel)`). Your `DeleteLink` sibling-count guard can become a straight `edge_id` delete whenever convenient; it stays correct as-is. Without `edge_id`, an ambiguous match is now **409** listing the candidate ids rather than deleting an arbitrary one. - **`PATCH /api/projax/edges` is new** — `{source, target, rel, metadata, edge_id?}`, shallow-merges metadata over stored and returns `{id, metadata}`. Built for your `ref_id` backfill. It merges rather than replaces, so `projax_link_origin` and `owner`/`repo` survive. Note the locator ignores the discriminator by design — a `{ref_id}` backfill could never match the edge it's meant to fix — so it's `edge_id` first, else `(target, rel)`, refusing if ambiguous. ### One data note for the backfill Against the live table: **81 of 82** `projax-*` edges carry no `ref_id` (43 `projax-mai-project`, 37 `projax-gitea-repo`, plus the **mHome** caldav edge, which has `url` but no `ref_id`). Those key to null and keep the old `(target, rel)` behaviour until PATCHed. The mHome one may be worth including in the backfill sweep — it's the only caldav edge in that state, and it's currently discriminating via the `url` fallback rather than `ref_id`. Flagged in case it's useful: `edgeMetadataForLink` writes `ref_id` unconditionally *today*, but rows written before that still exist — worth not assuming presence when the backfill reads them back.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: m/projax#8
No description provided.