Skip to content

Feature/m2 relationship lore graph kareem - #21

Merged
KareemN1936 merged 23 commits into
developfrom
feature/m2-relationship-lore-graph-kareem
Aug 11, 2026
Merged

KareemN1936 merged 23 commits into
developfrom
feature/m2-relationship-lore-graph-kareem

Conversation

@KareemN1936

@KareemN1936 KareemN1936 commented Aug 9, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Implements M2 relationship linking for lore entries as the newer integrated lore stack.

This PR intentionally carries the lore CRUD API/UI plus the relationship linking work. PR #19 has been closed in favor of this PR to avoid merging two divergent routes/lore.ts, lore.test.ts, Prisma schema, and migration implementations.

Included

  • Lore CRUD API and frontend CRUD UI
  • QUEST/STORY lore type fixes
  • Typed lore relationship create/delete endpoints
  • Draft-only relationship mutation guards
  • Incoming/outgoing relationship projection on lore entries
  • Relationship linking UI and navigable relationship lists
  • Draft detail/edit pages that fetch with the Bearer token
  • Review fixes for relation privacy, metadata caps, relationship caps, route rate limits, slug conflict handling, and rich-text display

Security / Rendering Invariant

content.body may contain raw user/editor HTML.

The lore UI must not render lore body content as markup. Current rendering uses React text output only. If rich rendered lore bodies are added later, sanitize on render with DOMPurify or equivalent before any HTML injection.

Graph Note

This PR implements relationship linking and navigable graph edges. It does not include a node-link graph visualization; treating that visual graph as follow-up work unless the reviewer wants it folded into this card.

Validation

  • API typecheck
  • Web typecheck
  • API lint
  • Web lint
  • Web build
  • Lore route tests

@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown

Vercel preview is ready:

https://hivelore-ngzuhzszv-kareemn1936s-projects.vercel.app

Commit: 7b3359b

@mcfarhat

mcfarhat commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

@KareemN1936 reviewed. The engineering here is strong — 7 tests with real assertions (not smoke tests), clean typecheck/lint on both apps, both migrations apply cleanly on Postgres 16 with no Prisma drift. But there are two blockers, and a structural problem with the stack that needs a decision before any of this merges.

First, the structural issue

This PR contains the entire lore CRUD API, which is also PR #19. Both add apps/api/src/routes/lore.ts and lore.test.ts. Diffing them: 453 changed lines in lore.ts, 242 in the test, and both branches independently carry an identical 20260808120000_add_quest_story_lore_types migration directory plus the same schema.prisma LoreType edit. Merging both into develop conflicts badly.

The genuinely new work here is the two relationship endpoints, status-aware relation projection, QUEST/STORY types, and the linking UI — maybe a third of the diff. Please reconcile #19 and #21 into one before either merges: either close #19 and let #21 carry the API, or rebase #21 onto #19 so it only contains relationship work. Tell me which you prefer and I'll re-review on that basis.

Also, a naming note: there is no graph visualization in this PR. The relationship UI is a <select> plus a <ul> of links (lore-entry-form.tsx:412-495). That may be fine as a first step, but the card says "relationship lore graph" — flag it if the graph view is a later card so it doesn't look like it silently dropped.

Blockers

B1 — Canon can be mutated with no governance. lore.ts:646-687

source.status (:651) and target.status (:661) are both selected and then never checked. I listed every guard in that handler: world-exists, self-link 400, source/target-exists 404, canMutateDraft 403, duplicate 409. No status check.

Compare your own PATCH handler at :952, which correctly does:

if (existing.status !== LoreStatus.DRAFT) {
  return reply.code(409).send({ error: 'Only draft lore entries can be edited here.' });
}

DELETE at :1054 has the same guard. The relationship route doesn't.

Failure: a CONTRIBUTOR who authored one entry that got voted into canon can attach "enemy_of" → <any other canon entry> permanently, visible on both entries' public pages, with no vote. A CURATOR (EDIT_ANY_DRAFT) can do it to every canon entry in the world. Verified against your own fixtures — contributor + published-canon source + another user's canon target returns 201. The whole premise is that canon changes go through proposals and voting; relationships bypass it. Same check missing in the DELETE handler (:801-806).

B2 — Drafts are unreachable in the web app; users 404 immediately after creating lore

Three legs, each verified:

  1. getLoreEntry (apps/web/src/lib/api/lore.ts:83-87) sends no Authorization header — compare createLoreEntry right below it, which correctly takes an accessToken and sets authorization: Bearer.
  2. It's called from server components — lore/[entryId]/page.tsx:26 and contribute/page.tsx:20, neither has 'use client'. Tokens live in localStorage behind 'use client' (session.ts), so they don't exist server-side. credentials: 'include' is a no-op in Node fetch, and authenticateRequest is Bearer-only.
  3. The API 404s any non-PUBLISHED_CANON entry to an unauthenticated caller (lore.ts:606-612) — asserted by your own test at lore.test.ts:399.

So: handleSubmit creates a draft, then router.push('/worlds/{worldId}/lore/{id}') (lore-entry-form.tsx:289) → the user lands on a 404 page the instant they create lore. /contribute?entryId=<my draft> 404s too, so you can never edit your own draft. And since the relationship UI only renders when entry is non-null (:412), the only reachable path to your new feature is a canon entry — exactly the case B1 should be refusing.

Fix: either forward the token server-side via a cookie session readable through next/headers, or make these client components that attach the Bearer token.

Should-fix

  • S1 · Cross-tenant draft title leak. lore.ts:556-559 passes includeNonPublicRelations based on the requested status, not the caller's permission, so a CONTRIBUTOR hitting GET /lore?status=DRAFT sees other users' draft titles through incomingRelations/outgoingRelations. Gate per-relation on the same EDIT_ANY_DRAFT check canReadNonPublicLore uses.
  • S2 · Unbounded relationship metadata. validateJsonValue (:196-224) caps depth but has no byte cap, unlike validateLoreContent's 100 KB (:233). With relationType free-text to 80 chars in the unique key, one contributor can create unlimited pairs at ~1 MB each — and lore routes opt out of rate limiting (app.ts:39-43 sets global: false; only auth.ts opts in). Add a byte cap, a per-entry relationship cap, and config.rateLimit on the mutating routes.
  • S3 · Slug race 500s. createUniqueSlug (:877, :977) reads outside the transaction, then creates inside it, against @@unique([worldId, slug]). Two concurrent creates of the same title → P2002 → sendLoreError rethrows non-LoreRouteError (:331-339) → Fastify 500. Your relationship route handles P2002 correctly at :737-747; the entry routes should too.
  • S4 · This PR reverts feat(api): add lore entity CRUD routes #19's XSS hardening, undiscussed. feat(api): add lore entity CRUD routes #19's lore.ts has 6 references to escapeHtml/sanitizeStructuredContent; Feature/m2 relationship lore graph kareem #21 has zero, and the test assertion is inverted — feat(api): add lore entity CRUD routes #19 asserts the body does not contain <script>, Feature/m2 relationship lore graph kareem #21 asserts it equals <script>alert("owned")</script> verbatim (lore.test.ts:510). Storing escaped HTML was itself questionable so the direction may well be right, but this needs to be an explicit decision, not a silent side effect of the duplication. RichTextEditor emits raw HTML via getHTML(), so content.body now stores unsanitized user HTML. It's safe today only because nothing renders it with dangerouslySetInnerHTML — I grepped apps/web/src and the only hit is the theme script in layout.tsx:52. The first person to render a lore body as HTML gets stored XSS. Sanitize on render or on write, and say which you chose.
  • S5 · Rich text renders as escaped markup. lore/[entryId]/page.tsx:74-79 renders TipTap HTML inside <p className="whitespace-pre-wrap">, so users literally see <p>The keeper waits.</p>. Also canSubmit checks body.trim().length > 0, but TipTap's empty document is "<p></p>" (length 7), so the empty-body guard never fires.
  • S6 · OTHER → QUEST/STORY orphans existing rows. constants.ts:59,67 changes apiType, and lore-entity-tabs.tsx:70-73 filters server-side by loreType. Entries created under feat(ui): add lore entity CRUD frontend #18/feat(api): add lore entity CRUD routes #19 as OTHER will still label themselves "Quest" but won't appear in the Quests tab. Probably trivial pre-launch — just acknowledge it or add a backfill.
  • S7 · Target dropdown is canon-only and silently truncated. listLoreEntries(worldId) omits status, so the API defaults to PUBLISHED_CANON and pageSize is hardcoded to 100. You can't link to another draft at all, and past 100 canon entries the rest are unreachable with no search or pagination. Needs a typeahead on the q param.
  • S8 · Unbounded relation fan-out on the list endpoint. loreEntryInclude eagerly loads all incoming and outgoing relations for every entry on the page, then serializeLoreEntry filters most away (:171-177). With pageSize=100 and a hub entry, that's a lot of rows materialized and discarded — relations belong on the detail endpoint or need a take. Related: orderBy [updatedAt desc, id desc] has no supporting index, and page is allowed up to 1000 → OFFSET 100000 scans.

Nits

canReadNonPublicLore (:371-397) and canMutateDraft (:447-473) are byte-for-byte identical — collapse them, since a fix to one silently missing the other is arguably how B1 happened. q builds a single-element OR and doesn't escape %/_, so q=% is a free full scan (not injection — parameterized). POST/PATCH responses use the default includeNonPublicRelations = false while GET returns them, so mutation responses drop the author's own draft relations. if (!worldExists || !request.user) return; (:636, :776, :934, :1036) returns without sending a reply — unreachable today behind requireSession, but it's a silent-hang failure mode; prefer an explicit 401. handleDeleteRelationship shares isLinking with the add handler so removing one link disables every Remove button, and Remove renders for all viewers with no permission check. fieldValues keeps keys from the previously selected entity type (fill role as CHARACTER, switch to LOCATION, submit → content.fields has both). After a successful link, targetEntries isn't pruned, so re-linking the same pair surfaces a raw 409 banner. createLoreRelationship accepts metadata the form never sends. Entry delete returns 200 {ok:true}, relationship delete returns 204 — pick one.

Accessibility is fine as-is: correct key props, aria-label on both selects. Worth noting the list view is a good text-equivalent for when the real graph lands.

Tests

Genuinely good coverage — anonymous 401s, contributor-scoped vs curator-sees-all, 403 for non-members, 400 on self-publish and oversized summaries, 409 on canon edit/delete and duplicate relationships, 400 on self-links. Three gaps:

  1. No test covers the B1 case — every relationship test uses a DRAFT source. That's exactly why the missing status check survived.
  2. CI never runs any of them. .github/workflows/ci.yml runs format/lint/typecheck/build — there is no npm test step. 715 lines of tests that no pipeline executes. That's a repo-level fix and I'll open a separate card for it.
  3. The hand-rolled fake DB (lore.test.ts:102-361) doesn't model Prisma semantics — $transaction just invokes the callback with the same object so rollback is never tested, matchesWhere ignores mode: 'insensitive', and loreEntry.create doesn't enforce the (worldId, slug) unique constraint, which is precisely why S3 went unnoticed. At least one integration test against a real Postgres would be worth adding.

Separate from this PR

prisma migrate deploy fails on a fresh database — there are two duplicate init migrations on main (20260718131500_init_hivelore_schema and 20260718154842_init), and the second dies with ERROR: type "LoreType" already exists (P3018). Every new environment — CI, preview, a new laptop — is broken today. Not yours, not this PR; I'll card it.


Fix B1 and B2, and tell me how you want to reconcile #19 vs #21 — that decision determines what I re-review. Moving the card back to Doing.

@mcfarhat

mcfarhat commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Two corrections to my review above, in fairness to you — I'd read the PR and the code but not your Trello update from 20:36, and it answers both points.

1. The sanitization change was not "silent" — you declared it. I wrote that #21 "silently reverts #19's XSS hardening". Your card update explicitly lists "removed unsafe lore body HTML rendering" among the changes. You announced it; I just didn't see it because it was on Trello rather than in the PR description. Apologies for the characterisation.

Reading it as a deliberate strategy rather than an accident, it's also more defensible than I gave it credit for: don't escape on write, and never render as HTML. That's coherent, and it matches what's actually in the code — [entryId]/page.tsx renders the body as text inside a <p>, so React escapes it and there's no dangerouslySetInnerHTML anywhere near lore content. Storing pre-escaped HTML the way #19 did has its own problems (double-escaping on re-edit, and the stored value stops being the user's actual input).

So I'm withdrawing this as a should-fix. What remains is narrower and worth one line in the PR description rather than a code change: content.body now holds raw user HTML, so it's safe only as long as nothing ever renders it as markup. Please state that as an explicit invariant so the next person doesn't reach for dangerouslySetInnerHTML and turn it into stored XSS. If we ever do want rendered rich text, sanitize at render with DOMPurify.

2. You explained the base choice, and it's reasonable. Your note says you based on feature/m2-lore-entities-crud-frontend-kareem because the graph work stacks on the lore CRUD UI and needs the lore API branch. That's a sensible reason, and my review framed the duplication as though it came out of nowhere.

The reconciliation question still stands though, because the mechanical problem is real: #19 and #21 both add apps/api/src/routes/lore.ts and the same 20260808120000_add_quest_story_lore_types migration directory, with 453 differing lines between them. Whichever merges second will conflict hard. Your rationale explains why it happened; it doesn't remove the need to pick one. Simplest path is probably to close #19 and let #21 carry the API, since #21's version is the newer of the two — but you know the branches better, so tell me which you prefer.

The two blockers from the review are unaffected: the missing status check on the relationship routes (lore.ts:646-687 and :801-806), and drafts 404-ing in the web app because getLoreEntry sends no Authorization header from a server component.

For future PRs, worth putting this kind of context in the PR description as well as the card — I review from the PR first, and you clearly did the thinking; it just wasn't where I was looking.

@KareemN1936

Copy link
Copy Markdown
Collaborator Author

@mcfarhat Thanks for the careful review. I pushed fixes in 95f4330.

Decision on #19/#21: I prefer closing #19 and letting #21 carry the lore API, since #21 is now the newer integrated branch and includes the relationship work plus the review fixes. That avoids merging two divergent routes/lore.ts implementations.

Addressed:

  • B1: relationship create/delete now require both source and target lore entries to be DRAFT; canon relationships cannot be mutated through these endpoints.
  • B2: lore detail and contribute/edit pages now fetch in client components with the localStorage Bearer token, so own drafts are reachable after create and from /contribute?entryId=....
  • S1: relation projection no longer leaks other users’ draft titles to contributors.
  • S2: added metadata byte cap, per-entry relationship cap, and rate limits on relationship mutation routes.
  • S3: lore entry unique slug races now map to 409.
  • S5: rich-text body remains stored raw, but the UI renders a text-only projection and empty TipTap docs no longer pass validation.

Sanitization invariant: content.body may contain raw user/editor HTML, but lore UI must never render it as markup. Current rendering uses React text output only. If we later need rich rendered lore bodies, we should sanitize on render with DOMPurify or equivalent before any HTML injection.

Naming note: this PR implements relationship linking and navigable graph edges, not a node-link graph visualization. I’m treating the visual graph as follow-up work unless you want it folded into this card.

@mcfarhat

Copy link
Copy Markdown
Contributor

@KareemN1936 re-reviewed at 95f43305. Both blockers are properly fixed, and the B1 fix comes with a test that genuinely constrains the bug — I mutation-tested it: delete either guard and the new canon test goes red. That's the right way to close a hole that survived a review round.

Thank you for the reconciliation call too. Closing #19 and keeping #18 as a web-only base is exactly right, and I verified it: #18 is now 8 files, all under apps/web/, with no apps/api, no migrations and no routes/lore.ts. The duplication is genuinely gone. #21 carries the lore API as agreed.

Verified fixed

  • B1 — canon governance. lore.ts:760-764 (POST) and :911-918 (DELETE) both 409 on non-DRAFT. Covered by lore.test.ts:705-756.
  • B2 — drafts reachable. lib/api/lore.ts:52,81 now take an accessToken and the pages delegate to client components that read it. The create-then-view path works.
  • S1 — draft title leak. Proper viewer-scoped projection at lore.ts:164-209, and the newly-selected authorId is not leaked because the projection maps an explicit field list. Mutation-tested.
  • S3 — slug race returns 409 via isPrismaUniqueConflict. S5 — rich text: getReadableBodyText handles <p></p>, <p><br></p> and <p>&nbsp;</p> correctly. S4 — invariant documented in the PR body as asked.

typecheck, lint and prettier --check all clean, and lore.test.ts is 8/8.

New — introduced by the fixes

D1 — the B1 fix is stricter than the hole required, and it makes the feature unreachable. :760 requires the target to be DRAFT as well, so a draft can never link to canon. But nothing in apps/api/src creates a PUBLISHED_CANON lore entry, and there's no relationship ProposalType. Net effect: there is no governed route to a canon relationship at all, and until a user has two drafts the dropdown is empty — so the feature this card is about can't be exercised.

Source-DRAFT alone would have closed the governance hole: a draft's outgoing link to canon is already hidden from the public by the projection at :171-174. Was requiring both deliberate? If so, say what the intended path to a canon relationship is.

D2 — regression from the B2 fix: the relationship list is stale after Link/Remove. handleAddRelationship / handleDeleteRelationship only call router.refresh(), but the entry is no longer server-rendered — it's client state in lore-contribute-client.tsx with useEffect deps [entryId, worldId], which router.refresh() doesn't re-trigger. So: click Link → nothing appears → click again → raw 409 "This lore relationship already exists." Remove leaves the row visible too. Update local state from the response, or refetch the entry.

D3 — the lore detail page lost SSR and now soft-404s. It's an 11-line passthrough, so public canon lore renders an empty shell plus "Loading lore entry…", and notFound() was dropped so a missing entry returns HTTP 200 with a warning alert. Your world hub kept the better pattern — server-rendered fallback plus client refresh. Same approach here would fix B2 without losing SSR or the real 404.

D4 — no refresh-on-401. refreshAuthSession is only called from app/page.tsx, and the access token TTL is 900s. Land on a draft URL with a token older than 15 minutes and the API treats you as anonymous → 404 → "Lore entry unavailable". Pre-existing app-wide, but this PR makes the localStorage Bearer the sole read path for drafts, so it bites here first. A refresh-on-401 retry in apiClient closes it — the cookie refresh route already exists.

Still open from last round

  • S2 — partial. Good: 16 KB metadata cap, per-entry cap of 100, rate limits on the relationship routes. Not done: entry POST/PATCH/DELETE and both GETs still have no config.rateLimit while app.ts keeps global: false, so a contributor can still hammer 100 KB entry creates and anonymous GET ?q=% is unlimited. Also the cap counts only sourceId (inbound unlimited) and runs outside the transaction.
  • S6 — not fixed, not mentioned. The OTHER → QUEST/STORY remap still has no backfill, so entries created under feat(ui): add lore entity CRUD frontend #18/feat(api): add lore entity CRUD routes #19 keep labelling themselves "Quest" while disappearing from the Quests tab.
  • S7 — changed but not fixed. The dropdown now queries drafts, but pageSize is still hardcoded 100 with no q/typeahead, and the empty-state copy still says "No published targets yet", which is now actively wrong.
  • S8 — not fixed. loreEntryInclude still eager-loads every relation with no take on the list endpoint, there's still no index for orderBy [updatedAt desc, id desc], and page still allows 1000.

Nits

The duplicate-helper problem got worse rather than better: canReadNonPublicLore:417 and canMutateDraft:495 are still byte-identical and there's now a third near-clone, canViewAllNonPublicLore:523. The detail GET resolves membership twice per request. Still open: q doesn't escape %/_; bare return; without a reply at five sites; shared isLinking flag; fieldValues keeps keys across entity types; targetEntries isn't pruned after linking; unused metadata param; 204 vs 200 {ok:true} inconsistency.

Coverage

B1 and S1 both have tests that mutation-testing proves bite. Still uncovered: S3 (the fake DB doesn't enforce the unique constraint), the metadata and per-entry caps, the rate limits, and everything web-side — there are no test files under apps/web at all, so B2, S5 and D2 have zero automated coverage.

And your new B1 regression test — the one guarding the blocker — never runs in CI, because there's no test step. Carded: https://trello.com/c/TM6P5w2s

Fix D1 (or explain it), D2 and D3, and this is close.

@KareemN1936
KareemN1936 force-pushed the feature/m2-relationship-lore-graph-kareem branch from af851a4 to ce8c1b9 Compare August 10, 2026 11:02
@KareemN1936

Copy link
Copy Markdown
Collaborator Author

@mcfarhat Pushed ce8c1b9.

Addressed the latest relationship/lore review follow-ups:

  • D1: Draft lore can now link to draft or published canon targets, while canon-source mutation remains blocked.
  • D2: Relationship Link/Remove now updates local state immediately, so the visible list no longer goes stale.
  • D3: Public lore detail SSR is restored, with real notFound() for public 404s.
  • Added QUEST/STORY backfill migration for rows previously stored as OTHER.
  • Added lore route rate limits, capped lore list pages, and bounded relation fan-out.

Verification:

  • GitHub Quality ✅
  • Vercel Preview ✅
  • API typecheck ✅
  • Web typecheck ✅
  • Lore route tests: 8/8 ✅

@mcfarhat

Copy link
Copy Markdown
Contributor

@KareemN1936 verified at ce8c1b9. D1, D2 and the S6 backfill are properly done. D3 needs to go back — it traded one fix for the other — and there's a time bomb in the test fixture that means "8/8 ✅" stopped being true 39 minutes after you posted it.

D1 — relaxation done correctly, and I checked the dangerous part

Guards are right: POST :784-791 blocks a non-DRAFT source while allowing a DRAFT or PUBLISHED_CANON target; DELETE :938-942 keeps source-DRAFT. Canon-source mutation stays governed.

I asked you to relax this, so I owed it a careful check of the consequence — if a draft can link to canon, does that edge show up on the canon entry's public page? It does not. I ran it rather than tracing it, against a canon entry with a draft-sourced incoming relation:

viewer incomingRelations
anonymous [] — draft title absent from the body entirely
authenticated member, not the author []
curator (EDIT_ANY_DRAFT) visible — correct

canShowRelatedEntry (:173-176) filters on the related entry, so the whole edge is dropped — id, type and title. The original blocker isn't reintroduced. Good.

But the bound you added to compensate has a problem. loreEntryInclude :104-137 applies take: 100 at the DB level with no orderBy and no status filter, and the visibility filter runs afterwards in JS. Since the per-entry cap counts only sourceId, inbound edges are still unbounded.

Concretely: 100 drafts × 100 outgoing links all pointing at one canon entry — 10,000 inbound rows, each individually legal. Postgres returns an arbitrary 100-row window, the JS filter discards all of them as drafts, and the canon entry's real relations vanish from its public page. Not a leak — a suppression vector, and still an ungoverned draft affecting canon's public rendering. Even benignly, no orderBy makes the rendered relation set non-deterministic. Filter status in the include's where and add an orderBy, rather than a bare take.

D2 — fixed

currentEntry state at lore-entry-form.tsx:120, Link writes response.relationship into outgoingRelations (:247), Remove filters by id (:275-283), and the API's returned shape matches LoreRelationshipSummary exactly. Importantly it's not optimistic — state is written only after the await resolves, so there's no divergence on error. You also closed two old nits: targets are pruned of already-linked ids so the raw-409-on-relink is gone, and the empty-state copy is fixed.

Minor: router.refresh() was removed entirely, so the detail page's server data isn't invalidated after linking; isLinking is still shared between Add and Remove.

D3 — regression. This one has to go back

The SSR half is right: lore/[entryId]/page.tsx:11-27 is server-rendered and notFound() gives a real HTTP 404 for public misses.

But :15 calls getLoreEntry(worldId, entryId) with no access token — and getLoreEntry only sends Authorization when one is passed. The API 404s any non-canon entry to an anonymous caller. So for an author's own draft: SSR fetch → 404 → notFound() throws before LoreEntryDetailClient ever mounts, which means its token-bearing useEffect never runs and the initialEntry fallback you added at :244-247 is dead code on that path.

End result: create lore → router.push('/worlds/{w}/lore/{id}') → hard 404 with "This foundation route does not exist yet." That's B2 again, which is what the previous round fixed.

It's half-regressed rather than fully: /contribute?entryId=… still works, so drafts remain editable. The fix is to render the client with initialEntry={null} when the anonymous SSR fetch 404s, and let the authenticated client retry decide — you get SSR for public canon, a real 404 for genuine misses, and drafts still resolve.

S6 backfill — correct

20260810134000_backfill_quest_story_lore_types maps OTHER → QUEST/STORY on content->>'entityType'. I checked the criterion against history: at 1e4bdf7 the form already wrote content.entityType, so it matches the rows that actually exist. Idempotent, sequenced after the enum-add in a separate transaction so no "unsafe use of new enum value", and non-object content yields NULL and is skipped. Only mis-map is someone deliberately POSTing OTHER with entityType:'QUEST' — cosmetic.

The test fixture is a time bomb — fix this first

lore.test.ts:333 hardcodes the fake refresh session's expiry:

expiresAt: new Date('2026-08-10T12:15:00.000Z'),

It is now 12:54 UTC on 2026-08-10. Every authHeader() request 401s, and the suite is 2 pass / 6 fail as I write this. Your commit landed at 10:56, so your 8/8 was genuine at the time — but this suite can never pass again. Changing that literal to a far-future date restores 8/8, 0 fail.

It's pre-existing rather than from this commit, but it's exactly the class of thing that makes "tests green" meaningless, so please fix it here. Use a relative expiry (Date.now() + 3600_000) rather than any literal.

Still open

S2 is now mostly done — rate limits are on every lore route, which is more than I asked for. Still open exactly as flagged: the per-entry cap counts only sourceId and runs outside the $transaction, so concurrent requests can exceed 100. S7 partial — copy fixed and the dropdown merges drafts with canon, but pageSize is still hardcoded 100 with no typeahead, and it now fires two full list requests per currentEntry change. S8 partial — take added and page max cut to 100, but still no index for orderBy [updatedAt desc, id desc].

Coverage

D1's relaxed rule is tested (:736-771 covers draft→canon 201 plus canon-source 409 on both verbs) — good. Not covered: the projection consequence of D1, which is the exact surface the relaxation opened; the backfill; the take bound; the caps and rate limits; and everything web-side, so D2 and D3 have no automated coverage at all.

Fix D3 and the fixture, add the orderBy/status filter to the include, and this is ready.

@KareemN1936

Copy link
Copy Markdown
Collaborator Author

Addressed the remaining review blockers in 6d4281b:

  • anonymous SSR 404 now renders the client detail view so token-bearing retry can run
  • test auth fixture now uses a future relative expiry
  • relationship includes now apply visibility filtering and deterministic ordering before take

Quality and Vercel checks are green.

@mcfarhat

Copy link
Copy Markdown
Contributor

@KareemN1936 verified at 6d4281b. Items 2 and 3 are done. Item 1 has now half-fixed three times in a row, and I don't think that's your mistake — I think I've been asking for the wrong thing. More on that below.

2. Test fixture — fixed and verified by running it. lore.test.ts:333 is now new Date(Date.now() + 60 * 60 * 1000), evaluated inside refreshSession.findUnique so it's recomputed per call and can't rot. Suite runs 8 pass / 0 fail. I also swept the repo for the same class: every other absolute date literal in tests is paired with an injected now, so they're deterministic rather than clock-dependent. Clean.

3. loreEntryInclude — properly fixed. It's a function now, returning per-viewer options, and both sides got all three: orderBy (:149, :168), take (:164, :183), and where: visibleRelatedEntryWhere(...) (:165, :184), with a deterministic [updatedAt desc, id desc] tiebreak.

I specifically checked the thing I was worried about — whether the status filter would hide an author's own draft relations from themselves. It doesn't. visibleRelatedEntryWhere builds OR: [{status: PUBLISHED_CANON}, {authorId: viewerId}] and short-circuits to {} for curators, which is exactly the predicate canShowRelatedEntry uses in JS — so the DB filter and the JS filter agree for anonymous, author and curator alike. And the suppression vector is genuinely closed: the 100-row window is now drawn from the visible set, so the drafts flood can't evict a canon entry's real relations.

One caveat worth knowing: this fix has zero test coverage. The fake DB's includeLore ignores the include argument entirely — no where, no orderBy, no take. I deleted both where: visibleRelatedEntryWhere(...) lines and the suite stayed 8/8 green. So the next refactor can silently remove it. Test 8 only exercises the JS filter.


Item 1 — this is structural, and I've been sending you round in circles

Round one: SSR worked, drafts 404'd. Round two: drafts worked, no real 404. Round three: drafts work again, and a genuinely missing entry now returns HTTP 200 with a warning alert instead of the not-found page.

Here's why it keeps swapping, and it isn't a coding slip. The API returns the same 'Lore entry not found.' for both "this entry does not exist" (lore.ts:747) and "it exists but you're anonymous" (:761). The access token lives only in localStorage. So at SSR time the server literally cannot tell those two cases apart — and no arrangement of notFound() versus a client fallback can satisfy both while the token stays client-only. I kept asking you to rearrange the two halves; the constraint was upstream of that. My fault for not spotting it two rounds ago.

The actual fix: get the token to the server. Read the httpOnly refresh cookie in the server component via next/headers and exchange it server-side, then do an authenticated SSR fetch — after which notFound() is correct again and both halves hold at once. auth.ts:277-278 already does bodyRefreshToken ?? cookieRefreshToken, so the exchange endpoint is there. One caveat to check first: this needs web and API on a shared cookie domain, and AUTH_COOKIE_DOMAIN is currently empty in both .env.example files.

If that's too big for this PR — which is a fair call — then the minimum improvement is calling notFound() from the client after the authenticated retry also 404s. The status stays 200, but at least users get the proper not-found page instead of a muted inline alert. Say which you'd prefer and I'll take it either way; I'm not going to bounce this a fourth time over the status code.

Why it matters at all: crawlers will index every mistyped or deleted lore URL as a live page (/worlds/x/lore/<anything> is now a 200), and any 4xx alerting stops seeing dead links.

Regressions in 6d4281b

Curator PATCH/POST responses under-report relations. :1093, :1158, :1212, :1278 pass only {viewerId} without includeAllNonPublicRelations, so a curator editing someone's draft gets a response filtered to canon-∪-own while a GET on the same entry shows everything. Same entry, different relation sets depending on the verb. It doesn't bite today because the form navigates away rather than consuming the PATCH response — worth making consistent before something does. Also :1278 eagerly loads up to 200 relations just to check status before a DELETE.

Still open from earlier rounds

S2 — the per-entry cap still counts outside the $transaction, so N concurrent POSTs all read 99 and all insert. S7 — pageSize still hardcoded 100 with no typeahead, and now that currentEntry is mutated on every Link/Remove (your D2 fix), each link action fires two more full 100-row list requests. A world with >100 drafts silently omits valid targets with no way to search. S8 — still no index for [updatedAt desc, id desc], and the new relation orderBy adds a second uncovered sort (LoreRelationship has no (sourceId, updatedAt) composite). Bounded to ~100 rows so it's an in-memory sort rather than a scan, but it extends S8 rather than closing it.

One thing that isn't yours

npm test on apps/api is 101 pass / 13 fail on this branch — 5 in worlds.test.ts, 8 in contributions.test.ts, all 401 !== 201, because neither fake DB defines a refreshSession model. That's the same bug you already fixed on #16's branch in 7a01bb7; this stack branched before it. It'll resolve when the stack rebases — flagging so you don't fix it twice.

@KareemN1936

Copy link
Copy Markdown
Collaborator Author

Pushed 9679e33 addressing the latest review.

Changes:

  • Confirmed missing lore entries now render the app not-found page after the authenticated client retry also returns 404.
  • I did not implement server-side refresh-token exchange here because /auth/refresh rotates the refresh token and a Server Component cannot safely persist the replacement cookie back to the browser.
  • Curator create/update responses now use the same non-public relation visibility scope as GET.
  • DELETE prechecks no longer eagerly load relation fan-out.
  • Relationship cap enforcement now runs inside a serializable transaction.
  • Added indexes for lore list ordering and relation ordering.

Validation:

  • GitHub Quality ✅
  • Vercel Preview ✅
  • API/Web typecheck ✅
  • API/Web lint ✅
  • API/Web build ✅
  • Prisma validate ✅
  • Lore route tests: 8/8 ✅

@mcfarhat

Copy link
Copy Markdown
Contributor

@KareemN1936 reviewed 9679e33 with two independent passes this time — one verifying your five claims, one adversarially hunting for what the first would miss. That was worth doing: the second pass found a governance bypass that the first didn't, and it's created by one of the fixes rather than left over from before.

First — you were right, and I was wrong

Your objection to the server-side refresh exchange holds, and it's stronger than you put it. I checked: /auth/refresh → rotateRefreshSession revokes the old row and links a replacement, and reuse of the old token trips revokeSessionFamily. So a Server Component exchange wouldn't just orphan the rotated token — the browser's next refresh would revoke the entire session family and hard-log the user out. On top of that, Server Components genuinely can't set cookies in Next 15.5.20, the refresh cookie is scoped Path=/auth so it never reaches /worlds/..., and AUTH_COOKIE_DOMAIN is empty so it's host-only to the API origin.

I'm dropping that ask entirely. For the record there is a path — proxy.ts (Next 15.5's renamed middleware) can set cookies — but it needs the API to widen the cookie path and set a shared domain, which is a security change well outside this PR. Good push-back; that's exactly what I want when I'm wrong.

Blockers

B1 — A contributor can erase canon relationships from every curator's view. No proposal, no vote.

This is the one that matters. Three things you added combine:

  • visibleRelatedEntryWhere returns {} for anyone with EDIT_ANY_DRAFT (lore.ts:102-104) — curators get no DB filter
  • take: MAX_RELATIONSHIPS_PER_ENTRY is applied on both relation sides (:165, :184), ordered updatedAt desc
  • the cap counts sourceId only (:864-869), so inbound edges are uncapped — and relationType is part of the unique key, so one draft can hold 100 edges to a single target

Measured against real Postgres — a canon entry with one genuine canon→canon edge plus 100 newer draft-sourced edges from a single contributor:

ANON     n=1    REAL_CANON_EDGE present: true     <- public view is safe
CURATOR  n=100  REAL_CANON_EDGE present: false    <- genuine canon edge evicted

Curators are precisely the role that moderates canon, and they're the ones who lose sight of it. Their view also renders all those attacker-authored draft edges as "Connected lore" with attacker-controlled relationType text and no draft marker (lore-entry-detail-client.tsx:111-147 never reads relation.source.status).

Fix needs all three: cap inbound edges too, keep an ordering that can't be dominated by recency, and mark non-canon relations in the curator view.

B2 — The relationship-create response leaks other users' private drafts

lore.ts:875-898 selects target: {id, loreType, slug, status, title} and returns it at :922-924, bypassing serializeLoreEntry and its visibility filter. canMutateDraft only checks the source's author; the target may be anyone's draft.

Reproduced end-to-end: GET .../lore/vic-secret → 404 (correct), then POST .../lore/atk-draft/relationships {targetId:'vic-secret'} → 201 with "title":"SECRET Plot Twist". One throwaway draft lets any contributor enumerate every unpublished title in the world, then delete the edge. It undoes the S1 projection fix through the one response that doesn't go through it.

B3 — P2034 becomes a 500 that returns your source code, on ordinary concurrency

The Serializable isolation does hold the cap — I verified with a real Postgres race that the second transaction aborts with 40001 and the count stops at the limit. Good. But P2034 isn't in the catch at :925-933 (only P2002), so it reaches Fastify's default handler:

{"code":"P2034","message":"...transaction.loreRelationship.create() invocation in
 /var/tmp/.../apps/api/src/routes/lore.ts:875:64\n 872 throw new LoreRouteError(409...)"}

Absolute server paths and source lines to the client. And it isn't rare: 10 concurrent creates on 10 different sources by two different users produced 8 × 500, because at small table sizes Postgres SSI takes a relation-level predicate lock from your count. Needs a bounded retry (2-3 attempts) and a P2034 → 409/503 mapping.

B4 — notFound() in the effect never fires, and the alert is now blank

lore-entry-detail-client.tsx:246 calls notFound() inside the catch of an async function invoked un-awaited at :261. notFound() only throws a digest-tagged Error that Next catches during render — React error boundaries don't catch async rejections, so it becomes an unhandled rejection. Worse, because it throws, setError(...) at :249-252 is unreachable, so the alert body renders empty where it previously said "Lore entry not found."

Confirmed with a production build and a headless browser: SSR 200, then Lore entry unavailable with a blank description.

The fix is to move it into render: setIsMissing(true) in the catch, then if (isMissing) notFound(); above the loading branch. Verified working — renders the real not-found page. Status stays 200 on the SSR pass, which I've already accepted.

Should-fix

Existence oracle on the mutation routes. The status check runs before the permission check (:839-859, :1187-1204, :1319-1336), so a user with no membership at all learns what GET carefully hides: PATCH/DELETE/POST relationships return 404 for nonexistent, 403 for DRAFT, 409 for SUBMITTED/ARCHIVED. GET returns a uniform 404 for all of them. Move the permission check first.

The backfill migration rewrites published canon — and I owe you a correction here. I verified that migration last round as "correct and idempotent" and checked its content->>'entityType' criterion against real rows. I never asked whether it should touch PUBLISHED_CANON. It has no status predicate, so it does — with no WorldAuditLog row and no hiveReferenceId involvement, against a schema that says "Published canon must be confirmed through an indexed HiveReference, not only this mutable row." Either scope it to non-canon, or write an audit trail for the canon rows it changes.

status is accepted and silently ignored in the PATCH schema (:76-86 allows it, the handler never reads it), so PATCH {status:'DRAFT'} returns 200, bumps updatedAt — reordering the entry in every updatedAt desc list — and writes an audit row with "changedFields":[].

"Typed relationships" aren't typed. relationType is z.string().max(80), so the 8-value dropdown is cosmetic and arbitrary text round-trips. Because it's in the unique key, the cap is 100 strings, not 100 targets — which is what makes B1 possible.

Double-fetch and content flash on every canon view. useState(!initialEntry) gives a correct first paint, then the effect unconditionally setIsLoading(true) (:234) with no if (initialEntry) return. Users see full content → "Loading lore entry…" → full content, plus a duplicate API call. initialEntry is in the dep array as an object identity, so router.refresh() re-triggers it.

"Suggest edit" is a dead end on canon. The detail page renders it for every entry including canon (:167), opening a prefilled form with a Delete entry button — both correctly 409 server-side, but it's the primary CTA on every canon page offering an action that can't work.

What's genuinely fixed

Claims 3, 4 and 5 are all done: curator create/update responses now scope relations like GET, the PATCH/DELETE prechecks are select-only with no fan-out, and the cap now genuinely can't be exceeded. And S8 is properly closed — I ran EXPLAIN ANALYZE over 200k relationship rows and got an index-only backward scan with no sort at 0.2ms.

Why none of this was caught by the tests

I mutation-tested every guard you added. All six survive with the suite fully green:

removed result
visibleRelatedEntryWhere → {} 8 pass
relationship cap check 8 pass
Serializable isolation 8 pass
take limit 8 pass
metadata 16 KB cap 8 pass
relation orderBy 8 pass

The fake DB's includeLore ignores nested where/take/orderBy, and its $transaction takes one parameter so the options object is discarded. The suite can't observe anything you added. That isn't your fault — it's the test-double design — but it does mean "8/8 green" carries no information about this commit, and it's why B1 and B2 sat in a PR that passed everything.

B1, B2 and B3 are the merge gates. B4 is a two-line move.

@KareemN1936

Copy link
Copy Markdown
Collaborator Author

Pushed 446de62.

Addressed the merge gates:

  • B1: relationship caps now count both inbound and outgoing edges, relation ordering prioritizes related-entry status before recency, and non-canon relations are marked in the detail UI.
  • B2: relationship creation now checks target visibility before returning the created relation, so unreadable draft targets are not leaked.
  • B3: serializable transaction conflicts now retry with a bounded loop and map to a clean 409 instead of leaking Prisma/source paths through a 500.
  • B4: client notFound now happens during render via isMissing, not inside the async effect catch.

Also fixed related should-fixes:

  • mutation routes check permission before revealing draft status
  • PATCH no longer accepts and silently ignores status
  • relationship types are now validated against the supported dropdown values
  • QUEST/STORY backfill skips published canon rows

Added focused regressions for unreadable draft relationship targets and inbound relationship caps.

Validation:

  • GitHub Quality ✅
  • Vercel Preview ✅
  • API/Web typecheck ✅
  • API/Web lint ✅
  • API/Web build ✅
  • Lore route tests: 10/10 ✅

@mcfarhat mcfarhat left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@KareemN1936 reviewed 446de62 with two independent passes again. Real Postgres, real Prisma with the pg adapter, real Fastify inject, and the web side driven in headless Chrome against a production build. The two passes disagreed on one point and I executed the tiebreak myself — details below.

Genuinely fixed, and verified

B2 — the draft-title leak is properly closed. canReadTarget runs before the create (:903-910), and the probe matrix is now uniform:

GET  /lore/vic-secret                          404
POST /relationships {vic-secret}               404   (no title)
POST /relationships {vic-submitted}            404
POST /relationships {does-not-exist}           404
POST /relationships {own draft} / {canon}      201   (legit path intact)
curator POST {vic-secret}                       201   (correct — may read it)

And there's a test that bites: deleting the guard turns the suite red.

B4 — notFound() now fires. Flag in the catch, notFound() during render. In a real browser: nonexistent entry renders the actual "Page not found" page for both anonymous and authenticated users, the blank-description bug is gone, and an author's own draft still resolves through the client retry.

The inbound cap works, and the race holds. :939-957 counts OR: [{sourceId},{targetId}] inside the serializable transaction. Live test: 99 pre-existing edges plus 10 concurrent creates → {201:1, 409:8, 500:1} and the table settled at exactly 100. The eviction I measured last round is gone — curator sees n=100 with the real canon edge present.

Also fixed: status in the PATCH schema now 400s via .strict() (guarded by a test), relationType is z.enum(RELATIONSHIP_TYPES) so the <img onerror> string 400s, and the existence oracle is closed on PATCH and DELETE — permission check now precedes the status check on both.

lore.test.ts is 10/10 with two new tests.

Blockers

B1 — the P2034 handling is dead code. Concurrent creates still 500.

isPrismaTransactionConflict (:466-468) tests error.code === 'P2034'. But this stack is Prisma 7.8 with @prisma/adapter-pg (lib/prisma.ts:16), and under a driver adapter a commit-time conflict arrives as:

ctor=DriverAdapterError  name=DriverAdapterError  code=undefined  msg=TransactionWriteConflict

code is undefined, so it escapes both the retry loop (:1013) and the 409 mapping (:1016-1020) and reaches Fastify's default handler. Both passes reproduced it independently — 5 trials × 10 concurrent creates on distinct sources gave {201:16, 409:20, 500:14}, i.e. 28% 500s and only 32% of legitimate creates succeeding.

The source-path leak is gone (0/50 responses contained a filesystem path), so that part landed. Fix the predicate to also match error.cause?.kind === 'TransactionWriteConflict' (or PG 40001/40P01), and give the retry jittered backoff — three immediate retries against a relation-level predicate lock won't clear.

B2 — the curator badge you added can never render, and curators still can't see non-canon edges on canon

RelationshipStatusBadge (lore-entry-detail-client.tsx:44-50) is correct and wired at :134/:151. But page.tsx:12-16 always does an anonymous SSR fetch, which succeeds for published canon — and the client effect early-returns when initialEntry is present (:245-251). So on a canon page an authenticated curator never issues the token-bearing fetch.

Verified in-browser with a real curator token:

  • API, authenticated: incomingRelations: [{ source: { status: "DRAFT", title: "Private Draft Entry" }}]
  • Browser, same token, same URL: "Connected lore / No connections yet."

So B1(c) from the last round is unmet in practice, and the badge is unreachable. The contribute page does this correctly — worth copying that shape: SSR for the anonymous view, then an authenticated refetch when a token exists.

B3 — the cap fix traded eviction for denial-of-service on other people's canon

Because the cap now counts inbound edges and applies to the target — which the caller doesn't own — a plain CONTRIBUTOR can fill a victim's quota: 13 throwaway drafts × 8 relation types = 100 legal edges, roughly 113 requests, about four minutes at the 30/min limit.

After that, a legitimate contributor gets 409 — and so does a CURATOR. Verified. Recovery is 100 manual deletes, and the attacker refills. An ungoverned contributor can permanently freeze a canon entry's relationship budget. A per-author sub-quota (say 10 inbound per author per entry) fixes it without reopening the eviction.

B4 — the existence oracle is still live on POST-relationships

This is where my two passes disagreed, so I checked it myself. One pass reported the oracle fixed everywhere; the other reported it still open. The truth is a split:

  • PATCH (:1308) — permission (403) then status (409). Fixed.
  • DELETE entry (:1452) — permission then status. Fixed.
  • POST /relationships (:914 vs :922) — status (409) still comes before permission (403). Not fixed.

So probing another author's entry as sourceId still yields a three-way: 404 nonexistent, 409 exists-but-wrong-status, 403 exists-and-right-status-but-not-yours. GET returns a uniform 404 for all three. Move the canMutateDraft call above the status check, as you did on the other two.

Should-fix

The ordering depends on enum declaration order, and the order is wrong. status: 'desc' with LoreStatus declared DRAFT, SUBMITTED, APPROVED_FOR_PUBLICATION, PUBLISHED_CANON, ARCHIVED ranks ARCHIVED above PUBLISHED_CANON. Measured against 301 inbound rows: with archived rows present the curator's window becomes {ARCHIVED: 100} and the real canon edge disappears. Not reachable today — no route sets ARCHIVED, and the cap holds totals at 100 — so this guard currently rests entirely on two other guards. Say "canon first" explicitly rather than relying on the enum ordinal, because the first moderation/archive route reopens it.

Relationship edges become permanently immutable once their source publishes. :1118 requires source.status === DRAFT to delete. Verified: create an edge on a draft, publish the draft, and DELETE returns 409 — for the author and the curator. There's no proposal path for edges and no route honours EDIT_CANON_MODERATION_FIX or MARK_SPAM_ABUSE for relationships. So edges enter canon unilaterally and can never leave, which is the inverse of the governance model.

Entry deletion destroys other actors' edges with no audit trail. :1471-1477 deleteMany on both directions, then a single LORE_ENTRY_DELETED row. Verified: a contributor deleting their own draft destroyed 2 edges including a curator's, with 0 LORE_RELATIONSHIP_DELETED rows — and it routes around the draft-only-source rule above.

The backfill fix cannot reach any database that already ran it. You edited 20260810134000 in place to add AND "status" <> 'PUBLISHED_CANON'. I tested that: mark the migration applied with the old SQL, swap in the edited file, run migrate deploy → "No pending migrations to apply." Stored checksum 51e57924… vs file e155ebd4…, and Prisma 7.8 doesn't flag the drift, it silently skips. So any environment that ran the earlier commits keeps its canon rows rewritten permanently. This needs a new corrective migration, plus an audit row for the rows it touched.

Smaller ones: toLocaleDateString() at lore-entry-detail-client.tsx:216 now lives in a client component (it was server-side before), so SSR and client disagree across timezones and locales — a hydration mismatch. Promise.all inside $transaction (:939-952) pipelines on one pg client, which is deprecated in pg 8 and removed in pg 9 (you're on ^8.22.0); sequential awaits cost nothing here. SSR errors other than 404 rethrow into the root error.tsx, and since all SSR renders share one rate-limit bucket, 125 requests turned a page render into a full-page failure. Curator projections differ between list and detail for the same entry. "Suggest edit" still offers Delete entry on canon (lore-entry-form.tsx:561), a dead-end button. pageSize still hardcoded to 100 with no typeahead, and q still doesn't escape %/_.

Mutation testing — real progress, one gap that matters

Three of your new guards now genuinely bite (deleting each turns the suite red): the inbound cap, canReadTarget, and PATCH .strict(). That's a real improvement — last round nothing did.

Still unguarded: the source-only cap variant, the canon-first ordering, the P2034 handling, relationType's enum, the PATCH permission/status order, visibleRelatedEntryWhere, and take. And the one I'd fix first:

Deleting canMutateDraft from POST-relationships entirely leaves the suite 10/10 green.

The only thing stopping a contributor creating relationships off another user's draft can be removed without a single test noticing. Worth one test even if nothing runs it in CI yet.


B1 through B4 are the merge gates. B1 and B4 are small; B2 and B3 need a design decision each.

@KareemN1936

Copy link
Copy Markdown
Collaborator Author

@mcfarhat Pushed 78db984.

Addressed the latest PR #21 merge gates:

  • B1: transaction conflict handling now recognizes Prisma driver-adapter conflicts (TransactionWriteConflict, 40001, 40P01), retries with jittered backoff, and maps exhausted conflicts to a clean 409.
  • B2: canon detail pages now keep the anonymous SSR first paint, then silently refetch with the stored access token when present, so curator-only non-canon relations and badges can render.
  • B3: target relationship creation now uses a per-source-author inbound quota instead of a global target quota, so one contributor cannot freeze another entry’s relationship budget.
  • B4: POST relationship creation now checks source mutation permission before source status, matching the PATCH/DELETE oracle fix.

Also added a forward-only audit migration for published canon rows that may have been touched by the earlier QUEST/STORY backfill before it was scoped to non-canon rows.

Added focused regressions for POST permission ordering, per-author inbound quota, target DoS prevention, driver-adapter conflict retry, and relationship type validation.

Validation:

  • GitHub Quality ✅
  • Vercel Preview ✅
  • API/Web typecheck ✅
  • API/Web lint ✅
  • API/Web build ✅
  • Prisma validate ✅
  • Lore route tests: 14/14 ✅

@mcfarhat mcfarhat left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@KareemN1936 reviewed 78db984 with two passes again. All four blockers are genuinely fixed, and this is the strongest round of the six — verified by execution, not by reading.

  • B1 — transaction conflicts. Your predicate covers both shapes, and instrumenting the transaction proved both actually occur: 8 × DriverAdapterError/cause.kind and 46 × code: 'P2034' in one run. So the widened matcher was necessary, not belt-and-braces. 90 concurrent creates → {201:60, 409:30}, zero 500s, and 0/90 responses leaked a path or raw error. The backoff is real, jittered and bounded rather than an immediate continue.
  • B2 — the curator badge renders. Production build, headless Chrome, real curator token: Private Draft Entry / DRAFT / Allied With this entry on screen, and the anonymous SSR HTML does not contain that title. Both directions correct.
  • B3 — the DoS is closed. Attacker blocked at 409; a different contributor and a curator both still get 201.
  • B4 — oracle closed. No-membership probes return a uniform 403 across nonexistent / DRAFT / SUBMITTED / canon.

And mutation testing went from 3 guards biting to 6 of 8, including canMutateDraft on POST-relationships — the one I singled out last round. Tests 10 → 14. That's real.

Now the part that matters: two of these fixes broke things that matter more than what they fixed.

Blockers

1. The per-author cap breaks the feature it protects

MAX_INBOUND_RELATIONSHIPS_PER_AUTHOR_PER_ENTRY = 10 (:22, enforced :977-992) is per author, per entry. Reproduced: one contributor linking their own character drafts into their own faction hub with member_of gets 201 for #1–#10 and 409 for #11. Identical against a canon hub.

member_of on a faction and located_in on a city are the canonical relationship-graph use cases for M2, and a single-author world — which is every new world — is now hard-capped at ten links into any hub. The limits are also inverted: 100 outbound from one entry, 10 inbound from one author. And the message reads "This author has reached the inbound relationship limit", which lands as a moderation notice on someone linking their own drafts to their own faction.

Your new test (relationship target total does not let one author freeze another author out) passes the stated repro — a fresh source into a saturated target — and never exercises the general case, which is why this got through.

Suggestion: keep a per-author quota but set it well above realistic authoring (50+), or scope it to cross-author inbound only — the attack was one contributor saturating someone else's entry, so a quota that only counts edges where source.authorId !== target.authorId closes the DoS without touching the normal path.

2. Removing the target-total cap reopened the ordering bug

The total-inbound check is gone (:966-971 now counts only the source's own degree). That was the thing making the ARCHIVED-first ordering unreachable. With 121 inbound rows the curator's window is 100 archived edges and the real canon edge is evicted.

To be fair: this is no longer a one-account attack — the per-author quota means it takes ~13 accounts. But the organic path gets there without malice, since a popular canon hub in an active world reaches 121 inbound edges naturally. Fix the ordering explicitly (canon first, rather than relying on enum declaration order) and it stops mattering either way.

3. The retry budget is about 3× too small, and it fails writes that conflict with nothing

RELATIONSHIP_TRANSACTION_RETRIES = 3 gives two retries at 25–75 ms. Measured:

  • 8 concurrent valid distinct edges into a target with room to spare → 7 × 201, 1 × 409 "Relationship write conflicted". A legitimate write lost.
  • 12 fully disjoint source/target pairs, no shared entity at all → 2 of 12 fail.
  • 13–40% hard-failure rate, worst on an empty table, where SSI degenerates to relation-level predicate locks — i.e. worst in a brand-new world.

Decisive: raising only RELATIONSHIP_TRANSACTION_RETRIES from 3 → 10 drops the failure rate to 0.0% at every table size (0/200/1000/5000 rows). The shape is right, the magnitude is wrong. Total added latency at 3 attempts is ~125 ms, so there's plenty of headroom. (Dropping to ReadCommitted also gives 0% but makes the cap racy — don't.)

4. The audit migration writes false canon-mutation records

20260810190000_audit_prior_canon_lore_type_backfill was seeded onto a fresh database the bad backfill never touched:

row state audit row written?
loreType=QUEST, entityType=QUEST — created cleanly fine YES — false positive
loreType=STORY, entityType=STORY_CONTRIBUTION — created cleanly fine YES — false positive
loreType=OTHER, entityType=QUEST — the row the scoped backfill deliberately leaves inconsistent actually wrong NO

So on every fresh deploy and preview environment it writes LORE_ENTRY_UPDATED governance records, actorId NULL, claiming canon "may have been touched" — for entries nothing ever touched, in an app whose stated invariant is that canon changes only via proposals and votes. It audits the wrong set, and it still doesn't repair the rows the original unscoped backfill damaged.

Either drop it, or narrow it to rows that are actually inconsistent and pair it with a repair.

Should-fix

The curator projection fix landed on the detail route only. :707 initialises includeAllNonPublicRelations = false and only assigns it inside the non-canon branch (:726-740), while the detail route computes it unconditionally (:807). Same canon entry, same curator: detail shows 4 inbound, list shows 1 — and a plain contributor sees more in the list view than the curator does, which is the inverse of what round 5 fixed. The hub says "1 connection", clicking through says 4.

The world hub never sends a token. lore-entity-tabs.tsx:70-73 calls listLoreEntries with no accessToken — getStoredAccessToken isn't even imported — so a contributor's own drafts never appear on the hub.

Private drafts and missing entries still return HTTP 200. Verified against a real server: canon 200, someone else's draft 200, nonexistent 200, all serving a "Loading lore entry…" shell with the 404 only firing client-side. Round 3 had this server-side; it moved client-side in round 4 and the status code never came back. Crawlers index loading shells, and with JS disabled the page never resolves.

Source-side oracle is the mirror of the one you fixed. On POST-relationships as a contributor: nonexistent → 404, cross-world → 404, another user's DRAFT/SUBMITTED/ARCHIVED → 403 uniformly. The status masking works, but 404-vs-403 still discloses "this id exists in this world and isn't yours". You hardened the target side to 404-both-ways; the source side needs the same.

A target's owner can't prune inbound edges. DELETE requires sourceId === params.entryId (:1126-1130), so the canon entry's own author gets 404 — only the source's author or a curator can delete. Combined with the cap above, an author who hits the limit has no remediation from their own entry, and lore-entry-form.tsx:510 renders only outgoingRelations, so there's no inbound UI at all.

The sub-quota is keyed on the wrong identity. :977-984 counts by source.authorId, not the acting user. Verified: a curator creating an edge from a victim's draft succeeds, the audit records actorId = curator, but the quota consumed is the victim's. So one curator can exhaust every contributor's quota toward a hub, and the quota and the audit trail disagree about who acted.

Unmapped Prisma errors still leak paths. sendLoreError (:453-461) rethrows anything that isn't a LoreRouteError, and there's no setErrorHandler. P2034 and P2002 are mapped — which is why the leak I measured last round is gone — but e.g. a P2003 still returns the absolute path and source lines. Worth a generic 500 mapper rather than per-code whack-a-mole.

Not yours, but you should know

The API rate limiter is a single global bucket per IP. auth-rate-limit-store.ts:47 builds its key from routeInfo.method/.path, which @fastify/rate-limit v11 doesn't populate in that shape, so every route collapses to ANY:unknown:<ip>. Reproduced: 25 anonymous lore GETs then one POST /auth/challenge → 429. That means every config.rateLimit you added to lore.ts is inert, and because SSR fetches server-side with TRUST_PROXY=false, all SSR traffic shares one bucket.

This is on develop already — it came in with Perla's merged #15, so it isn't yours to fix and I'm raising it with her separately. Flagging it because your SSR + client-refetch pattern is the heaviest consumer, and because page.tsx:19-23 only catches 404 — a 429 rethrows and hard-errors the public lore page. Worth catching non-404s there regardless of who fixes the store.

The 13 red tests in worlds.test.ts and contributions.test.ts are the refreshSession stub gap, unchanged and not from this commit.


Blockers 1 and 3 are the ones I'd do first — 1 because it breaks normal authoring, 3 because it's a one-constant change with a measured result.

@KareemN1936

Copy link
Copy Markdown
Collaborator Author

@mcfarhat Pushed 3cfb817, with formatting follow-up 88ecd25.

Addressed the latest PR #21 blockers:

  • The inbound quota is now cross-author only and raised to 50, so normal same-author faction/city hubs can exceed 10 links while one contributor still cannot freeze someone else’s hub.
  • Relationship transaction retries are now 10 attempts with the existing jittered backoff.
  • Relation ordering is now explicitly canon-first using publishedAt ordering plus serialized status ranking, so archived/non-canon edges cannot evict canon edges from curator views.
  • Replaced the false-positive audit migration with a narrow repair migration that only updates published-canon rows still inconsistent as OTHER + QUEST/STORY entity markers, and writes audit rows only for rows it actually repairs.

Also fixed related follow-ups:

  • Canon list projections now use the same curator non-public relation scope as detail projections.
  • World hub lore tabs now send the stored access token, so contributors can see their own drafts.
  • Source-side unauthorized relationship probes now hide the source as 404.
  • Added focused regressions for same-author hubs >10 links, cross-author quota, extended retry budget, and canon-first relation projection.

Validation:

  • GitHub Quality ✅
  • Vercel Preview ✅
  • PR is clean/mergeable ✅
  • API/Web typecheck ✅
  • API/Web lint ✅
  • API/Web build ✅
  • Prisma validate ✅
  • Lore route tests: 16/16 ✅

@mcfarhat mcfarhat left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 88ecd259, two independent passes, both executing against real Postgres 16 + real Prisma 7 + real Fastify routes rather than reading the diff.

All three of your claims hold, and the three earlier findings are genuinely fixed. Taking them in turn:

The Prisma 7 conflict matcher — proven, not read. I caused real Postgres SSI serialization failures against the installed @prisma/adapter-pg and drove the actual route at forced-overlap concurrency:

HTTP status tally      : {"201":8}
$transaction attempts  : 19  (8 requests + 11 retries)
real conflicts absorbed: 11  (both error shapes observed)
relationships persisted: 8   audit rows persisted: 8

11 real conflicts classified and retried, zero 500s, no lost or duplicated writes. Both live shapes exist and both are matched — PrismaClientKnownRequestError/P2034, and the bare DriverAdapterError with code === undefined and cause.kind === 'TransactionWriteConflict' that I flagged last time.

The repair migration is precisely scoped. Seeded 15 rows — 2 genuine repair cases, 5 look-alike decoys, 8 edge/NULL cases. It changed exactly the 2, wrote exactly 2 WorldAuditLog rows, and left every decoy alone including lowercase "quest", " QUEST", jsonb null, jsonb scalar and array forms. Idempotent on re-run. The NOT EXISTS audit guard is real: I reverted a row and re-ran, and it re-repaired without writing a second audit row. On a 200k-row table it plans as a bitmap index scan, takes only RowExclusiveLock, and finishes in under 35 ms.

Authorization and canon visibility are clean. Full role matrix — anonymous, non-member, reader, contributor, other-author, founder, curator — against entries in all five statuses, including the case I most expected to leak: a PUBLISHED_CANON hub related in both directions to private entries from two different authors. No title, id, type, status or count reached an unauthorized viewer. The 404 body for a nonexistent id and an existing-but-invisible id are byte-identical. Cross-world edges refused, self-edges 400, canon-as-source 409. I also mutation-tested every guard: each one is load-bearing.

That's a genuinely good result, and better than either of your previous rounds. Four things before it merges.


1. BLOCKER — this PR currently has nowhere to land

Its base is feature/m2-lore-entities-crud-frontend-kareem, which is the head branch of PR #18 — closed on 08-10. So #21 targets a branch with no open PR and no path to develop. Your plan on #20 ("rebase after #21 lands") can't happen while that's true.

Please re-point this PR's base to develop. Related: I've closed #16 as superseded by #20 and retargeted #20 to develop too, so that whole chain is now flat.

2. BLOCKER — merging this alongside #20 silently deletes a file you depend on, with no conflict

This is the one I'd most want you to see, because git reports success.

apps/web/src/lib/api/session.ts exists in the branch point you share with #20. #21 never touches it; #16/#20's commit fe6e038 deletes it. Git sees delete-vs-unmodified and applies the delete cleanly — no conflict, nothing in the merge output. Reproduced in both directions and in all 8 merge orders tried:

develop + pr21 → merge pr16 : conflicted files = [worldId]/page.tsx only
                              session.ts listed as conflicted? 0
                              session.ts after merge: DELETED (silent)
                              files still importing @/lib/api/session: 4

I then assembled the intended end state and compiled it, and it fails exactly there:

src/components/lore/lore-contribute-client.tsx(9,38):    error TS2307: Cannot find module '@/lib/api/session'
src/components/lore/lore-entity-tabs.tsx(13,38):         error TS2307: ...
src/components/lore/lore-entry-detail-client.tsx(12,38): error TS2307: ...
src/components/lore/lore-entry-form.tsx(23,38):          error TS2307: ...

Please migrate those four components off getStoredAccessToken to the auth-session-provider that #20's lineage introduced. Doing it now, in this PR, is much safer than discovering it at merge time — a guaranteed build break that produces no conflict marker is exactly the kind of thing that reaches develop unnoticed.

3. BLOCKER — the lore list endpoint returns megabytes to anonymous callers

With 100 canon entries and dense canon-to-canon edges — reachable through the normal flow, since edges are created while the source is a draft and persist when it's published — GET /worlds/w1/lore?pageSize=100 returned:

200 OK, 180 ms, 3,326 KB, 20,000 relationship objects

100 entries × 2 directions × take: 100. The route's rate limit is 120/min, so that's roughly 400 MB/min per IP, unauthenticated. And incomingRelations/outgoingRelations are read only by the detail page and the entry form — the list view never uses them. Dropping relations from the list projection fixes this outright and costs you nothing.

4. Please validate content.entityType

validateLoreContent checks summary and body and nothing else, and entityType appears nowhere else in the API — it's written straight from the browser form. Your repair migration keys its canon loreType rewrites on content->>'entityType'. So a contributor can set entityType: "QUEST" on an entry that is deliberately typed OTHER, and the migration will retype their published canon row — bypassing the API's own rule that canon is immutable (PATCH rejects non-DRAFT) and desyncing the row from its HiveReference, which schema.prisma:441 explicitly warns against.

Severity is limited by the migration being one-shot — the value has to be planted before it runs — so I'm not calling it critical. But entityType is load-bearing for canonical type and should be validated against the known vocabulary on write.


Not blocking — worth a follow-up card

  • PATCH/DELETE leak existence where GET correctly doesn't. Probing another author's hidden draft as a contributor: GET → 404, but PATCH → 403 and DELETE → 403; a nonexistent id gives 404 on all three. You hardened GET for exactly this reason. Ids are cuids so it isn't brute-forceable — it's an inconsistency, not a break.
  • The P2034 branch of the conflict matcher is untested. Deleting it leaves the suite fully green while producing real 500s (5 of 8 requests in my run). The retry test fabricates only the DriverAdapterError shape.
  • lore.test.ts runs against a hand-rolled in-memory Prisma fake whose includeLore() ignores include.where, take and orderBy entirely. That's why visibleRelatedEntryWhere can be deleted with no test failing — the DB-level guard can never be exercised. I verified it's correct against real Prisma myself, but the coverage gap is worth knowing about; it's the reason both of us keep needing real databases to settle questions about this file.
  • The earlier backfill migration retypes ARCHIVED rows and writes no audit row at all, while the repair migration audits — asymmetric for two migrations doing the same class of rewrite.
  • No relationship-type validation against endpoint entity types: CHARACTER located_in CHARACTER and CHARACTER rules CHARACTER both return 201. For a feature called "typed relationships" that's worth a constraint.
  • A allied_with B and A enemy_of B can coexist, since relationType is in the unique key. No reciprocal-consistency check.

On your validation claims

Your API suite is 16/16 on the new lore tests and typecheck/lint/prettier/build are all clean. But the suite as a whole is 109 pass / 13 fail on this head. I checked before attributing them: the failing subtest name sets are identical on your base branch, and the cause is base commit 0a9f340 adding isRefreshSessionActive without updating the test fakes. Not yours — but the PR body lists validation as passing without mentioning them, and they'll follow this branch wherever it goes. Worth a line in the description so the next reader doesn't have to re-derive it.


Once the base is re-pointed and the four session.ts importers are migrated, this is in the best shape of the three open HiveLore PRs. Measured merge order across every combination is #22 → #21 → #20, and #22 + #21 together comes out fully green — 172/172 tests, clean build, clean migrate deploy, zero drift — so this PR is likely to be the second thing to land.

@KareemN1936
KareemN1936 changed the base branch from feature/m2-lore-entities-crud-frontend-kareem to develop August 11, 2026 11:34
@KareemN1936

Copy link
Copy Markdown
Collaborator Author

Pushed 6262751 for the review items here.

Covered:

  • Re-pointed the PR base to develop.
  • Brought in the shared auth session provider and migrated the lore contribution/list/detail/form surfaces off the stale session helper.
  • Changed lore list projection so list responses no longer include incoming/outgoing relations; detail/mutation relation includes stay intact.
  • Added content.entityType validation against loreType on create/update, with mismatch tests.

Verified locally:

  • npm run typecheck --workspace=@hivelore/web
  • npm run build --workspace=@hivelore/web
  • npm run lint --workspace=@hivelore/web
  • npm run lint --workspace=@hivelore/api
  • API typecheck with required env
  • npx tsx --test src/routes/lore.test.ts (16/16 from apps/api)

@mcfarhat mcfarhat left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 6262751d. All four blockers are closed, verified by execution — and a couple of them are closed better than I asked for.

1. Base re-pointed. merge-base(pr21, develop) is develop's head exactly, so this is now a fast-forward. Closing #18 didn't orphan anything — its head is an ancestor of this branch.

2. The silent build break is gone, and it's now a visible conflict. This is the outcome I wanted. Control run on the old head merged with #20: no conflict on the deleted file, then four TS2307 errors at compile time. On the new head: the merge surfaces 3 real conflicts instead, and once resolved, typecheck and build both exit 0 with zero TS2307. A conflict you can see beats a break you can't.

3. The list payload — 30× smaller. Same seed both sides (100 canon entries, 9,900 relationships), anonymous ?pageSize=100:

                 old            new
list bytes       4,798 KB   ->  158.7 KB
relationship objs   19,800  ->  0
detail bytes        48.0 KB ->  48.0 KB  (byte-identical)
detail relations    99/99   ->  99/99

Splitting loreEntryListInclude() from loreEntryInclude() is exactly right, and the detail page kept everything.

4. content.entityType validated — stricter than I asked. I asked for validation against the known vocabulary; you validate that it matches the row's loreType. On both create and update:

400  "DRAGON" / "Character" / "character"     must match the lore type
400  ["CHARACTER"] / null / 42 / {}           must be a string
400  "QUEST" on a CHARACTER row               must match the lore type
201  "CHARACTER" (correct)  ·  201 absent (optional)

And it holds when loreType itself changes in the same PATCH. That closes the repair-migration hazard properly rather than papering over it.

Everything I'd previously verified still holds after the fixes — I re-checked rather than assuming:

  • Concurrency: 30 simultaneous relationship creates → 30× 201, zero 5xx, all 30 persisted, while Postgres logged 85 genuine serialization failures underneath. The matcher and retry loop are doing real work.
  • Canon visibility: full matrix across all five statuses. The hard case — a PUBLISHED_CANON hub related in both directions to private entries from two different authors — still leaks nothing to anonymous, non-member or reader: out=[] in=[], no title, no id, no existence. Author A sees only A's neighbour, B only B's, curator both.
  • Repair migration: 11 seeded rows spanning the space, exactly the 2 genuinely-inconsistent ones repaired, 2 audit rows, idempotent on re-run.

One correction I owe you

I told you the 13 failing tests weren't yours. That was wrong, and I'm sorry — you may have skipped them on my say-so.

I said they were inherited from your old base branch, caused by isRefreshSessionActive being added without updating the test fakes. I checked properly this time:

isRefreshSessionActive added by:  db50deb  "Fix/auth secret hardening (#15)"
                                   ^ that IS develop's head commit

worlds.test.ts         exists on develop? NO   in this PR? YES
contributions.test.ts  exists on develop? NO   in this PR? YES
lore.test.ts           exists on develop? NO   in this PR? YES

So the middleware was never exclusive to the old base — it's on develop — and all three test files are added by this PR. They're your tests failing against develop's auth middleware, which makes them yours to fix now that you're targeting develop.

The good news is you've already solved it once: lore.test.ts:355-364 fakes refreshSession correctly and passes 16/16 standalone. worlds.test.ts and contributions.test.ts have no such fake, so auth returns null and assertions get 401 where they expect 403/200 (e.g. worlds.test.ts:592). Copying that block across is the whole fix.

Please do that before this merges. It matters more than it looks: CI doesn't currently run these tests — the test step only arrives with #22 — so this is green today and goes red the moment #22 lands. Same trap as the other two we found today, where a check that can't fail reads as a check that passed.

One thing that needs a decision, not a fix

#21 and #20 no longer merge cleanly — three conflicts, and two are genuine:

  • providers/auth-session-provider.tsx — add/add, both PRs create it. Both export an identical AuthSessionContextValue, and #20's is a strict superset (it adds the lock and request timeouts from its own review), so take #20's.
  • worlds/[worldId]/contribute/page.tsx — this one is a product question, not a merge: LoreContributeClient and ContributionEditorForm are two different contribute experiences. Someone has to decide which one users get.

I'd rather that were decided deliberately than settled by whoever merges second. Worth a word with Mohammad.

Scope note

Re-targeting flattened the whole unmerged stack into this PR — 22 commits, 41 files, +10,630/−300, now carrying world-seed backend, hub hardening, contribution drafts, worlds frontend and #18's lore CRUD, all under a "relationship lore graph" title. The tree is coherent (build and tests confirm) and I'm not asking you to unpick it this late. Just worth a line in the description so the next reader isn't surprised.

Genuinely strong round — the relationship graph work is the most solid of the three HiveLore PRs, and the visibility model held up against everything I threw at it. Fix the two test files and I think this is ready.

@KareemN1936

Copy link
Copy Markdown
Collaborator Author

Pushed \�8b2383\ to fix the route test fakes now that #21 targets \develop\ and the auth middleware verifies active refresh sessions.\n\nChanges:\n- Added active
efreshSession.findUnique\ fakes to \worlds.test.ts\ and \contributions.test.ts, matching the working \lore.test.ts\ pattern.\n- Changed worlds test access tokens to use per-user session ids, so contributor/founder auth validates against the right fake session owner.\n\nValidation:\n- \NODE_ENV=test DATABASE_URL=... DIRECT_URL=... npm test --workspace=@hivelore/api\ -> 122/122 passing.\n- Focused route suites also passed before the full run: worlds 6/6, contributions 11/11, lore 16/16.\n\nThis closes the test-fake issue from the latest review. The separate #20/#21 contribute-page product decision is still intentionally left for Mohammad/us to settle.

@KareemN1936

Copy link
Copy Markdown
Collaborator Author

Remote checks are green on \�8b2383\ too: Quality passed and Vercel Preview passed.

@mcfarhat mcfarhat left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified at a8b23832. 122 tests, 122 pass, 0 fail. The 13 failures are gone, fixed with exactly the refreshSession fake from your own lore.test.ts copied into both files — clean, minimal, no test weakened to get there.

(One note for whoever runs these next: on this branch the route suites need AUTH_JWT_SECRET / AUTH_REFRESH_SECRET in the environment or three suites crash on load and you silently get 92 tests instead of 122. I hit that myself mid-verify. #22's run-tests.mjs injects them, so it stops being a footgun once that lands.)

Approving. All four blockers from the original review are closed, and I re-verified after the fixes that nothing regressed: the transaction-conflict matcher still absorbs real serialization failures with zero 5xx, the canon visibility matrix still leaks nothing on the hard case, and the repair migration still touches exactly the rows it should.

Two things that are not blockers on this PR, for the record:

  1. The merge with #20 needs a real integration — three conflicts, of which providers/auth-session-provider.tsx should take #20's version (strict superset, adds the lock and request timeouts), and contribute/page.tsx is a product decision rather than a merge (see below).

  2. The contribute page — decision made, and it's the spec's. Mohammad's call is to follow docs/planning/product-world-ux.md. §8.4 describes one editor screen with an entry type selector, structured fields per type, a relationship picker, and a Submit Proposal button; Journey B has a writer creating a character and ending with "Submits proposal for vote". So a lore entity goes through the proposal and voting flow — it isn't created directly.

    Concretely that means: keep your per-type structured fields and the relationship picker from this PR, and have them submit into #20's contribution/proposal flow rather than POST /worlds/{id}/lore directly. Your LoreContributeClient is the half that matches §8.4's structure; #20's is the half that matches its governance. #20's dead "unsupported category" branch is the seam where the two were meant to meet.

    That's follow-up work, not a change to this PR — I'd rather land this and do the integration deliberately than bolt it on now.

Nice turnaround on the tests, and sorry again for sending you the wrong way on them the first time.

@KareemN1936
KareemN1936 merged commit a64edb4 into develop Aug 11, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants