diff --git a/frontend/src/lib/timeline/EventCluster.svelte b/frontend/src/lib/timeline/EventCluster.svelte
index bbf7da3c..024ba168 100644
--- a/frontend/src/lib/timeline/EventCluster.svelte
+++ b/frontend/src/lib/timeline/EventCluster.svelte
@@ -3,7 +3,7 @@ import * as m from '$lib/paraglide/messages.js';
import LetterCard from './LetterCard.svelte';
import GlyphLabel from './GlyphLabel.svelte';
import { entryKey } from './entryKey';
-import { getAccentConfig } from './eventCardConfig';
+import { getAccentConfig, canEditEvent } from './eventCardConfig';
import { timelineDateLabel } from './dateLabel';
import { CLUSTER_PREVIEW } from './eventClustering';
import type { components } from '$lib/generated/api';
@@ -51,7 +51,7 @@ const provenance = $derived(
event?.derived ? m.timeline_provenance_derived() : m.timeline_provenance_curated()
);
const eventSubtitle = $derived(eventDateLabel ? `${eventDateLabel} · ${provenance}` : provenance);
-const canEdit = $derived(canWrite && !!event && !event.derived && event.eventId != null);
+const canEdit = $derived(!!event && canEditEvent(event, canWrite));
// First-5 preview + show-more (REQ-003): a large cluster stays readable instead of dumping every
// card into the timeline.
diff --git a/frontend/src/lib/timeline/EventPill.svelte b/frontend/src/lib/timeline/EventPill.svelte
index b8573119..5a4afb17 100644
--- a/frontend/src/lib/timeline/EventPill.svelte
+++ b/frontend/src/lib/timeline/EventPill.svelte
@@ -1,6 +1,6 @@
diff --git a/frontend/src/lib/timeline/WorldBand.svelte b/frontend/src/lib/timeline/WorldBand.svelte
index 9dbab74f..ff3da75f 100644
--- a/frontend/src/lib/timeline/WorldBand.svelte
+++ b/frontend/src/lib/timeline/WorldBand.svelte
@@ -1,6 +1,6 @@
diff --git a/frontend/src/lib/timeline/eventCardConfig.spec.ts b/frontend/src/lib/timeline/eventCardConfig.spec.ts
index 8fd33355..ac1b49cb 100644
--- a/frontend/src/lib/timeline/eventCardConfig.spec.ts
+++ b/frontend/src/lib/timeline/eventCardConfig.spec.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
-import { getAccentConfig } from './eventCardConfig';
+import { getAccentConfig, canEditEvent } from './eventCardConfig';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
@@ -51,3 +51,24 @@ describe('getAccentConfig', () => {
expect(cfg.accent).toBe('curated');
});
});
+
+// The single source of the curator edit-affordance gate (CLAUDE.md's TimelineEntryDTO contract):
+// a curated event shows its edit pencil only for a writer, never for a derived life-event or a
+// null eventId. Shared by EventPill, WorldBand, and EventCluster (#850 finding #5).
+describe('canEditEvent', () => {
+ it('allows a writer to edit a curated event with an eventId', () => {
+ expect(canEditEvent(event({ derived: false, eventId: 'e-1' }), true)).toBe(true);
+ });
+
+ it('denies a viewer without write permission', () => {
+ expect(canEditEvent(event({ derived: false, eventId: 'e-1' }), false)).toBe(false);
+ });
+
+ it('denies a derived life-event even for a writer', () => {
+ expect(canEditEvent(event({ derived: true, eventId: 'e-1' }), true)).toBe(false);
+ });
+
+ it('denies an event with no eventId even for a writer', () => {
+ expect(canEditEvent(event({ derived: false, eventId: undefined }), true)).toBe(false);
+ });
+});
diff --git a/frontend/src/lib/timeline/eventCardConfig.ts b/frontend/src/lib/timeline/eventCardConfig.ts
index cb11d7b2..7d0aeb1f 100644
--- a/frontend/src/lib/timeline/eventCardConfig.ts
+++ b/frontend/src/lib/timeline/eventCardConfig.ts
@@ -36,3 +36,16 @@ export function getAccentConfig(entry: TimelineEntryDTO): AccentConfig {
}
return { glyph: '★', label: m.timeline_layer_family(), accent: 'curated' };
}
+
+/**
+ * The curator edit-affordance gate, in one place — the security-relevant contract documented on
+ * CLAUDE.md's `TimelineEntryDTO` row (`derived || eventId == null` → no edit link). A curated
+ * event's edit pencil shows only for a viewer with WRITE_ALL (`canWrite`), and only when it is a
+ * real curated event: never a derived life-event (nothing to edit) and never a null `eventId`.
+ * HISTORICAL events are never derived, so this also covers the world band. The gate is UX only —
+ * the #781 route guard + backend permission are the real boundary. Shared by EventPill, WorldBand,
+ * and EventCluster so the gate has a single source of truth (#850 finding #5).
+ */
+export function canEditEvent(entry: TimelineEntryDTO, canWrite: boolean): boolean {
+ return canWrite && !entry.derived && entry.eventId != null;
+}