Skip to main content

pharos_native/runtime/
event_discovery.rs

1//! Evidence-backed event ingestion for apps declaring an event-discovery contract.
2//! Public sources are fetched only by the background worker, through the bounded transport.
3
4use std::collections::BTreeMap;
5use std::time::Duration;
6
7use pharos_runtime_database::operations::{
8    Mutation, Predicate, Query, SortKey, Transaction, WriteValue,
9};
10use serde_json::{Map, Value, json};
11
12mod calendar_feed;
13mod model;
14mod social_announcements;
15pub(super) mod social_evidence;
16
17use super::{
18    AppRuntime, app_database_operations, current_timestamp_utc, dynamic, runtime_timestamp_seconds,
19};
20
21type PreviewRegistry = BTreeMap<String, (AppRuntime, std::time::Instant)>;
22
23const PROFILE_FIELDS: [&str; 8] = [
24    "website",
25    "instagram",
26    "facebook",
27    "source_url",
28    "soundcloud",
29    "spotify",
30    "youtube",
31    "x_url",
32];
33
34const SOURCE_CHECK_INTERVAL_SECONDS: u64 = 12 * 60 * 60;
35
36/// Identifies providers that require an authorized connector instead of a public-page fetch.
37fn requires_social_authorization(url: &url::Url) -> bool {
38    pharos_runtime_social::provider::SocialProvider::from_url(url).is_some()
39}
40
41/// Recognizes common soft-login documents only when no usable event evidence was extracted.
42fn password_gate(body: &str) -> bool {
43    let lower = body.to_ascii_lowercase();
44    lower.contains("type=\"password\"")
45        || lower.contains("type='password'")
46        || lower.contains("type=password")
47        || ["<title>login", "<title>log in", "<title>sign in"]
48            .iter()
49            .any(|title| lower.contains(title))
50}
51
52/// Starts a managed discovery poller so external sources cannot delay ordinary queued jobs.
53pub(super) fn start_worker(
54    stack: std::sync::Arc<std::sync::RwLock<super::RuntimeStack>>,
55) -> Option<super::job::JobWorkerState> {
56    if !stack.read().ok()?.apps.iter().any(|app| {
57        app.queue_enabled
58            || app.app_id.as_deref() == Some("architect")
59            || dynamic::dynamic_contract_doc(app, "social_connections_contract")
60                .and_then(|value| value.get("enabled").and_then(Value::as_bool))
61                == Some(true)
62    }) {
63        return None;
64    }
65    let state = super::job::JobWorkerState::default()
66        .with_poll_interval_ms(5000)
67        .with_poll_handler(std::sync::Arc::new(move || {
68            let Some(apps) = stack.read().ok().map(|stack| stack.apps.clone()) else {
69                return;
70            };
71            for app in &apps {
72                if super::runtime_shutdown_requested() {
73                    return;
74                }
75                #[cfg(not(target_os = "wasi"))]
76                super::social_connections::process_one(app);
77                process_one_source(app);
78            }
79            if !super::runtime_shutdown_requested() {
80                process_registered_previews();
81            }
82        }));
83    super::job::job_worker_start(&state, None).ok()?;
84    Some(state)
85}
86
87/// Keeps bounded accepted preview runtimes available to the shared background worker.
88fn preview_registry() -> &'static std::sync::Mutex<PreviewRegistry> {
89    static REGISTRY: std::sync::OnceLock<std::sync::Mutex<PreviewRegistry>> =
90        std::sync::OnceLock::new();
91    REGISTRY.get_or_init(|| std::sync::Mutex::new(BTreeMap::new()))
92}
93
94/// Registers an explicitly enabled discovery contract in its existing isolated preview namespace.
95pub(super) fn register_preview(app: &AppRuntime) {
96    if !app.is_editor_preview {
97        return;
98    }
99    let enabled = dynamic::dynamic_contract_doc(app, "event_discovery_contract")
100        .and_then(|value| value.get("enabled").and_then(Value::as_bool))
101        == Some(true)
102        || dynamic::dynamic_contract_doc(app, "social_connections_contract")
103            .and_then(|value| value.get("enabled").and_then(Value::as_bool))
104            == Some(true);
105    let (Some(config), Some(id)) = (&app.runtime_conf_path, &app.app_id) else {
106        return;
107    };
108    let Ok(mut registry) = preview_registry().lock() else {
109        return;
110    };
111    let now = std::time::Instant::now();
112    registry.retain(|_, (_, seen)| now.duration_since(*seen) < Duration::from_secs(86400));
113    let key = format!("{config}\0{id}");
114    if !enabled {
115        registry.remove(&key);
116        return;
117    }
118    if registry.len() >= 32 && !registry.contains_key(&key) {
119        return;
120    }
121    let mut preview = app.clone();
122    preview.queue_enabled = true;
123    registry.insert(key, (preview, now));
124}
125
126/// Processes one registered preview per poll with bounded round-robin scheduling.
127pub(super) fn process_registered_previews() {
128    static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
129    let preview = preview_registry().lock().ok().and_then(|registry| {
130        if registry.is_empty() {
131            return None;
132        }
133        let index = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) % registry.len();
134        registry
135            .values()
136            .nth(index)
137            .filter(|(_, seen)| seen.elapsed() < Duration::from_secs(86400))
138            .map(|(app, _)| app.clone())
139    });
140    if let Some(preview) = preview {
141        #[cfg(not(target_os = "wasi"))]
142        super::social_connections::process_one(&preview);
143        process_one_source(&preview);
144    }
145}
146
147/// Verified event facts or an attributed public day-only announcement; unrelated feed prose is excluded.
148#[derive(Debug, Clone, PartialEq)]
149struct DiscoveredEvent {
150    key: String,
151    title: String,
152    starts_at: String,
153    ends_at: String,
154    venue: String,
155    address: String,
156    country: String,
157    timezone: String,
158    event_state: String,
159    state_explicit: bool,
160    organizers: Vec<String>,
161    event_url: String,
162    ticket_url: String,
163    status: String,
164    evidence: String,
165    evidence_source: String,
166}
167
168/// Retains inert source facts with a host-authored reason for private owner review.
169#[derive(Clone)]
170struct ReviewCandidate {
171    facts: Value,
172    reason: &'static str,
173    accepted_event_id: Option<i64>,
174}
175
176/// Accepts bounded HTTPS links without credentials, controls or executable schemes.
177fn public_link(value: &str) -> Option<String> {
178    Some(super::outbound_url::https_url(value)?.to_string())
179}
180
181/// Loads bounded active organizer sites; storage/capacity failures never retire sources or widen authority.
182fn organizer_sites(store: &super::database::LogicalAppStore) -> Result<Vec<url::Url>, ()> {
183    if !store
184        .schema
185        .entities
186        .iter()
187        .any(|entity| entity.name == "organizer")
188    {
189        return Ok(Vec::new());
190    }
191    let rows = store
192        .adapter
193        .query(
194            &store.config,
195            &store.schema,
196            &Query {
197                entity: "organizer".into(),
198                fields: vec!["website".into()],
199                filter: Some(Predicate::Equal("crm_status".into(), json!("active"))),
200                order: vec![],
201                limit: 1000,
202                offset: 0,
203            },
204        )
205        .map_err(|_| ())?;
206    if rows.len() == 1000 {
207        return Err(());
208    }
209    Ok(rows
210        .iter()
211        .filter_map(|row| {
212            row.get("website")
213                .and_then(Value::as_str)
214                .and_then(super::outbound_url::https_url)
215        })
216        .collect())
217}
218
219/// Compares declared site origins while tolerating only the conventional www alias.
220fn same_site(left: &url::Url, right: &url::Url) -> bool {
221    left.host_str()
222        .map(|host| host.strip_prefix("www.").unwrap_or(host))
223        == right
224            .host_str()
225            .map(|host| host.strip_prefix("www.").unwrap_or(host))
226        && left.port_or_known_default() == right.port_or_known_default()
227}
228
229/// Treats social hosts as profile scopes even when pasted into a website field.
230fn declared_site_allows(target: &url::Url, site: &url::Url) -> bool {
231    if !same_site(target, site) {
232        return false;
233    }
234    let host = site.host_str().unwrap_or_default();
235    let social = requires_social_authorization(site)
236        || ["youtube.com", "youtu.be", "soundcloud.com", "spotify.com"]
237            .iter()
238            .any(|domain| {
239                host == *domain
240                    || host
241                        .strip_suffix(domain)
242                        .is_some_and(|prefix| prefix.ends_with('.'))
243            });
244    if !social {
245        return true;
246    }
247    let path = site.path().trim_end_matches('/');
248    !path.is_empty()
249        && target.query() == site.query()
250        && (target.path().trim_end_matches('/') == path
251            || target.path().starts_with(&format!("{path}/")))
252}
253
254/// Limits discovery to explicitly declared artist/social/organizer sites, excluding catalog provenance and link hubs.
255fn eligible_source(value: &str, artist: &Map<String, Value>, organizers: &[url::Url]) -> bool {
256    let Some(url) = super::outbound_url::https_url(value) else {
257        return false;
258    };
259    let host = url.host_str().unwrap_or_default();
260    if [
261        "bing.com",
262        "google.com",
263        "duckduckgo.com",
264        "search.yahoo.com",
265        "linktr.ee",
266        "linktree.com",
267    ]
268    .iter()
269    .any(|blocked| {
270        host == *blocked
271            || host
272                .strip_suffix(blocked)
273                .is_some_and(|prefix| prefix.ends_with('.'))
274    }) {
275        return false;
276    }
277    if artist
278        .get("source_url")
279        .and_then(Value::as_str)
280        .and_then(super::outbound_url::https_url)
281        .is_some_and(|catalog| {
282            catalog
283                .path()
284                .split('/')
285                .any(|part| matches!(part, "artists" | "artist" | "djs" | "dj"))
286                && same_site(&url, &catalog)
287                && (url.path().trim_end_matches('/') == catalog.path().trim_end_matches('/')
288                    || url
289                        .path()
290                        .starts_with(&format!("{}/", catalog.path().trim_end_matches('/'))))
291        })
292    {
293        return false;
294    }
295    if artist
296        .get("website")
297        .and_then(Value::as_str)
298        .and_then(super::outbound_url::https_url)
299        .is_some_and(|site| declared_site_allows(&url, &site))
300        || organizers
301            .iter()
302            .any(|site| declared_site_allows(&url, site))
303    {
304        return true;
305    }
306    PROFILE_FIELDS
307        .iter()
308        .filter(|field| !matches!(**field, "website" | "source_url"))
309        .any(|field| {
310            artist
311                .get(*field)
312                .and_then(Value::as_str)
313                .and_then(super::outbound_url::https_url)
314                .is_some_and(|profile| {
315                    let path = profile.path().trim_end_matches('/');
316                    !path.is_empty()
317                        && same_site(&url, &profile)
318                        && url.query() == profile.query()
319                        && (url.path().trim_end_matches('/') == path
320                            || url.path().starts_with(&format!("{path}/")))
321                })
322        })
323}
324
325/// Matches performer names without guessing identities from partial substrings.
326fn normalized_name(value: &str) -> String {
327    value
328        .split_whitespace()
329        .collect::<Vec<_>>()
330        .join(" ")
331        .to_lowercase()
332}
333
334/// Collects performer names from the bounded JSON-LD value.
335fn performer_matches(value: &Value, name: &str) -> bool {
336    match value {
337        Value::String(value) => normalized_name(value) == normalized_name(name),
338        Value::Array(values) => values.iter().any(|value| performer_matches(value, name)),
339        Value::Object(value) => value
340            .get("name")
341            .is_some_and(|value| performer_matches(value, name)),
342        _ => false,
343    }
344}
345
346/// Extracts one complete event, requiring an explicit time zone, performer and venue.
347fn event_from_value(
348    value: &Value,
349    artist: &str,
350    source: &str,
351    now: u64,
352) -> Option<DiscoveredEvent> {
353    let types = value.get("@type")?;
354    let is_event = |kind: &str| {
355        matches!(
356            kind.rsplit('/').next().unwrap_or(kind),
357            "Event" | "MusicEvent" | "Festival"
358        )
359    };
360    if !types.as_str().is_some_and(is_event)
361        && !types
362            .as_array()
363            .is_some_and(|types| types.iter().filter_map(Value::as_str).any(is_event))
364    {
365        return None;
366    }
367    if !performer_matches(value.get("performer")?, artist) {
368        return None;
369    }
370    let title = value.get("name")?.as_str()?.trim();
371    let raw_start = value.get("startDate")?.as_str()?;
372    if raw_start.len() < 20
373        || !(raw_start.ends_with('Z') || raw_start.get(19..)?.contains(['+', '-']))
374    {
375        return None;
376    }
377    let start = runtime_timestamp_seconds(raw_start)?;
378    if start < now.saturating_sub(30 * 86400)
379        || start > now.saturating_add(730 * 86400)
380        || title.is_empty()
381        || title.len() > 300
382    {
383        return None;
384    }
385    let venue = value.pointer("/location/name")?.as_str()?.trim();
386    if venue.is_empty() || venue.len() > 300 {
387        return None;
388    }
389    let event_url = value
390        .get("url")
391        .and_then(Value::as_str)
392        .and_then(public_link)
393        .unwrap_or_else(|| source.to_owned());
394    let ticket = value
395        .pointer("/offers/url")
396        .or_else(|| value.pointer("/offers/0/url"))
397        .and_then(Value::as_str)
398        .and_then(public_link)
399        .unwrap_or_default();
400    let end = if let Some(raw) = value.get("endDate") {
401        let raw = raw.as_str()?;
402        if raw.len() < 20 || !(raw.ends_with('Z') || raw.get(19..)?.contains(['+', '-'])) {
403            return None;
404        }
405        let end = runtime_timestamp_seconds(raw)?;
406        if end < start {
407            return None;
408        }
409        pharos_runtime_core::time::timestamp_from_epoch_seconds(end)
410    } else {
411        String::new()
412    };
413    let (status, event_state) = match value
414        .get("eventStatus")
415        .and_then(Value::as_str)
416        .unwrap_or("")
417        .rsplit('/')
418        .next()?
419    {
420        "EventCancelled" => ("cancelled", "cancelled"),
421        "EventPostponed" => ("review", "postponed"),
422        "EventRescheduled" => ("published", "rescheduled"),
423        "EventScheduled" | "" => ("published", "scheduled"),
424        _ => ("review", "unknown"),
425    };
426    let key = model::event_identity(value, source, start, venue, title, &event_url)?;
427    let evidence = json!({"extraction_version":1,"event":value}).to_string();
428    if evidence.len() > 7680 {
429        return None;
430    }
431    let (address, country) = model::location(value)?;
432    let organizers = model::organizer_names(value)?;
433    Some(DiscoveredEvent {
434        key,
435        title: title.to_owned(),
436        starts_at: pharos_runtime_core::time::timestamp_from_epoch_seconds(start),
437        ends_at: end,
438        venue: venue.to_owned(),
439        address,
440        country,
441        timezone: if raw_start.ends_with('Z') {
442            "UTC".into()
443        } else {
444            raw_start.get(raw_start.len().checked_sub(6)?..)?.to_owned()
445        },
446        event_state: event_state.into(),
447        state_explicit: value
448            .get("eventStatus")
449            .and_then(Value::as_str)
450            .is_some_and(|state| !state.is_empty()),
451        organizers,
452        event_url,
453        ticket_url: ticket,
454        status: status.to_owned(),
455        evidence,
456        evidence_source: source.to_owned(),
457    })
458}
459
460/// Traverses only bounded JSON-LD graphs; arbitrary nested documents are not crawled.
461fn collect_events(
462    value: &Value,
463    artist: &str,
464    source: &str,
465    now: u64,
466    depth: usize,
467    output: &mut BTreeMap<String, DiscoveredEvent>,
468    review: &mut Vec<ReviewCandidate>,
469) {
470    if depth > 8 || output.len() + review.len() >= 100 {
471        return;
472    }
473    let event = event_from_value(value, artist, source, now);
474    let in_window = value
475        .get("startDate")
476        .and_then(Value::as_str)
477        .and_then(runtime_timestamp_seconds)
478        .is_none_or(|start| {
479            start >= now.saturating_sub(30 * 86400) && start <= now.saturating_add(730 * 86400)
480        });
481    if event.is_none()
482        && in_window
483        && value.get("@type").is_some_and(|kind| {
484            let event_type = |name: &str| {
485                matches!(
486                    name.rsplit('/').next(),
487                    Some("Event" | "MusicEvent" | "Festival")
488                )
489            };
490            kind.as_str().is_some_and(event_type)
491                || kind
492                    .as_array()
493                    .is_some_and(|kinds| kinds.iter().filter_map(Value::as_str).any(event_type))
494        })
495        && value.to_string().len() <= 7680
496    {
497        review.push(ReviewCandidate {
498            facts: value.clone(),
499            reason: "Incomplete date, venue or exact artist evidence; not published",
500            accepted_event_id: None,
501        });
502    }
503    if let Some(event) = event {
504        match output.entry(event.key.clone()) {
505            std::collections::btree_map::Entry::Occupied(mut entry) => {
506                if entry.get() != &event {
507                    entry.get_mut().status = "review".to_owned();
508                    review.push(ReviewCandidate {
509                        facts: value.clone(),
510                        reason: "Conflicting event declarations; not published",
511                        accepted_event_id: None,
512                    });
513                }
514            }
515            std::collections::btree_map::Entry::Vacant(entry) => {
516                entry.insert(event);
517            }
518        }
519    }
520    match value {
521        Value::Array(values) => {
522            for value in values.iter().take(100) {
523                collect_events(value, artist, source, now, depth + 1, output, review);
524            }
525        }
526        Value::Object(values) => {
527            if let Some(graph) = values.get("@graph") {
528                collect_events(graph, artist, source, now, depth + 1, output, review);
529            }
530        }
531        _ => {}
532    }
533}
534
535/// Reads application/ld+json blocks or a JSON event feed without executing source markup.
536#[cfg(test)]
537fn extract_events(body: &str, artist: &str, source: &str, now: u64) -> Vec<DiscoveredEvent> {
538    extract_events_with_review(body, artist, source, now).0
539}
540
541/// Retains bounded incomplete structured evidence privately; only validated facts become public events.
542fn extract_events_with_review(
543    body: &str,
544    artist: &str,
545    source: &str,
546    now: u64,
547) -> (Vec<DiscoveredEvent>, Vec<ReviewCandidate>) {
548    if body.len() > 1024 * 1024 || public_link(source).is_none() {
549        return (Vec::new(), Vec::new());
550    }
551    let mut output = BTreeMap::new();
552    if body.trim_start().starts_with("BEGIN:VCALENDAR") {
553        return (
554            calendar_feed::extract(body, artist, source, now).unwrap_or_default(),
555            Vec::new(),
556        );
557    }
558    let mut review = Vec::new();
559    if let Ok(value) = serde_json::from_str::<Value>(body) {
560        collect_events(&value, artist, source, now, 0, &mut output, &mut review);
561    } else {
562        let lowercase = body.to_ascii_lowercase();
563        let mut lower_remainder = lowercase.as_str();
564        let mut remainder = body;
565        for _ in 0..100 {
566            let Some(start) = lower_remainder.find("<script") else {
567                break;
568            };
569            remainder = &remainder[start + 7..];
570            lower_remainder = &lower_remainder[start + 7..];
571            let Some(end_tag) = lower_remainder.find('>') else {
572                break;
573            };
574            let header = &remainder[..end_tag];
575            remainder = &remainder[end_tag + 1..];
576            lower_remainder = &lower_remainder[end_tag + 1..];
577            let Some(end) = lower_remainder.find("</script>") else {
578                break;
579            };
580            if header.to_ascii_lowercase().contains("application/ld+json")
581                && let Ok(value) = serde_json::from_str::<Value>(&remainder[..end])
582            {
583                collect_events(&value, artist, source, now, 0, &mut output, &mut review);
584            }
585            remainder = &remainder[end + 9..];
586            lower_remainder = &lower_remainder[end + 9..];
587        }
588    }
589    (output.into_values().collect(), review)
590}
591
592/// Builds literal values for parameterized generic database mutations.
593fn literals(values: Value) -> BTreeMap<String, WriteValue> {
594    values
595        .as_object()
596        .into_iter()
597        .flatten()
598        .map(|(key, value)| (key.clone(), WriteValue::Literal(value.clone())))
599        .collect()
600}
601
602/// Removes stale or unattributed social review material without changing accepted gigs or owner decisions.
603fn prune_social_reviews(store: &super::database::LogicalAppStore, now: u64) -> Result<(), ()> {
604    if !store
605        .schema
606        .entities
607        .iter()
608        .any(|entity| entity.name == "event_observation")
609    {
610        return Ok(());
611    }
612    let rows = store
613        .adapter
614        .query(
615            &store.config,
616            &store.schema,
617            &Query {
618                entity: "event_observation".into(),
619                fields: vec!["id".into(), "source_id".into(), "facts".into()],
620                filter: Some(Predicate::Equal("review_status".into(), json!("review"))),
621                order: vec![SortKey {
622                    field: "id".into(),
623                    descending: false,
624                }],
625                limit: 1000,
626                offset: 0,
627            },
628        )
629        .map_err(|_| ())?;
630    let mut steps = Vec::new();
631    for row in rows {
632        let raw = row.get("facts").and_then(Value::as_str).ok_or(())?;
633        let facts: Value = serde_json::from_str(raw).map_err(|_| ())?;
634        if facts.get("accepted_event_id").is_some() {
635            continue;
636        }
637        let Some(candidate) = facts.get("candidate") else {
638            continue;
639        };
640        let social =
641            candidate.get("evidence_kind").is_some() || candidate.get("source_profile").is_some();
642        if !social || social_evidence::eligible(candidate, now) {
643            continue;
644        }
645        // Retain deduplication identity, not leaked account panels or unrelated personal-stream prose.
646        steps.push(Mutation::Update {entity:"event_observation".into(),filter:Predicate::All(vec![Predicate::Equal("id".into(),row["id"].clone()),Predicate::Equal("source_id".into(),row["source_id"].clone()),Predicate::Equal("facts".into(),row["facts"].clone()),Predicate::Equal("review_status".into(),json!("review"))]),values:literals(json!({"review_status":"rejected","facts":json!({"extraction_version":2,"review_reason":"Excluded: stale, retrospective or unverified post attribution/date evidence","candidate":{}}).to_string()})),expected_rows:Some(1)});
647    }
648    for batch in steps.chunks(100) {
649        store
650            .adapter
651            .transact(
652                &store.config,
653                &store.schema,
654                &Transaction {
655                    steps: batch.to_vec(),
656                    idempotency_key: None,
657                },
658            )
659            .map_err(|_| ())?;
660    }
661    Ok(())
662}
663
664/// Deduplicates private evidence using bounded batches matching the shared predicate budget.
665fn pending_review_evidence(
666    store: &super::database::LogicalAppStore,
667    source: i64,
668    candidates: Vec<ReviewCandidate>,
669) -> Result<Vec<(String, String)>, ()> {
670    let mut evidence = BTreeMap::new();
671    for candidate in candidates {
672        let mut facts = json!({"extraction_version":1,"candidate":candidate.facts,"review_reason":candidate.reason});
673        if let Some(id) = candidate.accepted_event_id {
674            facts["accepted_event_id"] = json!(id);
675        }
676        let facts = facts.to_string();
677        if facts.len() > 8192 {
678            return Err(());
679        }
680        let hash =
681            pharos_runtime_database::operations::operation_fingerprint(&facts).map_err(|_| ())?;
682        evidence.insert(hash, facts);
683    }
684    if evidence.is_empty() {
685        return Ok(Vec::new());
686    }
687    let hashes = evidence.keys().cloned().collect::<Vec<_>>();
688    let mut rows = Vec::new();
689    for batch in hashes.chunks(64) {
690        rows.extend(
691            store
692                .adapter
693                .query(
694                    &store.config,
695                    &store.schema,
696                    &Query {
697                        entity: "event_observation".into(),
698                        fields: vec!["evidence_hash".into(), "review_status".into()],
699                        filter: Some(Predicate::All(vec![
700                            Predicate::Equal("source_id".into(), json!(source)),
701                            Predicate::In(
702                                "evidence_hash".into(),
703                                batch.iter().map(|hash| json!(hash)).collect(),
704                            ),
705                        ])),
706                        order: vec![],
707                        limit: 64,
708                        offset: 0,
709                    },
710                )
711                .map_err(|_| ())?,
712        );
713    }
714    for row in rows {
715        if row
716            .get("review_status")
717            .and_then(Value::as_str)
718            .is_some_and(|status| matches!(status, "rejected" | "reviewed" | "accepted"))
719            && let Some(hash) = row.get("evidence_hash").and_then(Value::as_str)
720        {
721            evidence.remove(hash);
722        }
723    }
724    Ok(evidence
725        .into_iter()
726        .map(|(hash, facts)| (facts, hash))
727        .collect())
728}
729
730/// Reads one declared source entity without backend-specific persistence.
731fn query(
732    store: &super::database::LogicalAppStore,
733    entity: &str,
734    fields: &[&str],
735    filter: Predicate,
736) -> Option<Map<String, Value>> {
737    store
738        .adapter
739        .query(
740            &store.config,
741            &store.schema,
742            &Query {
743                entity: entity.to_owned(),
744                fields: fields.iter().map(|field| (*field).to_owned()).collect(),
745                filter: Some(filter),
746                order: vec![],
747                limit: 1,
748                offset: 0,
749            },
750        )
751        .ok()?
752        .into_iter()
753        .next()
754}
755
756/// Synchronizes one artist's profile links using a durable scan cursor and bounded writes.
757/// Existing sources retain an owner's pause state; catalog row identities are resolved from storage.
758fn synchronize_one_artist(
759    store: &super::database::LogicalAppStore,
760    organizers: &[url::Url],
761) -> bool {
762    let scope = Predicate::Equal("scope_key".into(), json!("artist_source_scan"));
763    let counter = query(store, "crm_counter", &["last_value"], scope.clone());
764    let cursor = counter
765        .as_ref()
766        .and_then(|row| row.get("last_value"))
767        .and_then(Value::as_i64)
768        .unwrap_or(0);
769    let Ok(rows) = store.adapter.query(
770        &store.config,
771        &store.schema,
772        &Query {
773            entity: "artists".into(),
774            fields: std::iter::once("csv_row_id")
775                .chain(PROFILE_FIELDS)
776                .map(str::to_owned)
777                .collect(),
778            filter: Some(Predicate::All(vec![
779                Predicate::GreaterThan("csv_row_id".into(), json!(cursor)),
780                Predicate::Equal("crm_status".into(), json!("active")),
781            ])),
782            order: vec![SortKey {
783                field: "csv_row_id".into(),
784                descending: false,
785            }],
786            limit: 1,
787            offset: 0,
788        },
789    ) else {
790        return false;
791    };
792    let next = rows
793        .first()
794        .and_then(|row| row.get("csv_row_id"))
795        .and_then(Value::as_i64)
796        .unwrap_or(0);
797    if cursor == 0 && next == 0 {
798        return false;
799    }
800    let guard = if counter.is_some() {
801        Predicate::All(vec![
802            scope.clone(),
803            Predicate::Equal("last_value".into(), json!(cursor)),
804        ])
805    } else {
806        scope
807    };
808    let mut steps = vec![
809        Mutation::RequireCount {
810            entity: "crm_counter".into(),
811            filter: guard,
812            minimum: u64::from(counter.is_some()),
813            maximum: Some(u64::from(counter.is_some())),
814        },
815        Mutation::Upsert {
816            entity: "crm_counter".into(),
817            unique_key: vec!["scope_key".into()],
818            values: literals(json!({"scope_key":"artist_source_scan","last_value":next})),
819        },
820    ];
821    if let Some(artist) = rows.first() {
822        let stale = match store.adapter.query(
823            &store.config,
824            &store.schema,
825            &Query {
826                entity: "event_source".into(),
827                fields: vec!["id".into(), "url".into(), "last_status".into()],
828                filter: Some(Predicate::All(vec![
829                    Predicate::Equal("artist_row_id".into(), json!(next)),
830                    Predicate::NotEqual("last_status".into(), json!("source_removed")),
831                ])),
832                order: vec![],
833                limit: 1000,
834                offset: 0,
835            },
836        ) {
837            Ok(rows) => rows,
838            Err(_) => return false,
839        };
840        let mut artist_guard = vec![
841            Predicate::Equal("csv_row_id".into(), json!(next)),
842            Predicate::Equal("crm_status".into(), json!("active")),
843        ];
844        for field in PROFILE_FIELDS {
845            artist_guard.push(Predicate::Equal(field.into(), artist[field].clone()));
846        }
847        steps.push(Mutation::RequireCount {
848            entity: "artists".into(),
849            filter: Predicate::All(artist_guard),
850            minimum: 1,
851            maximum: Some(1),
852        });
853        for source in stale.iter().filter(|source| {
854            !source
855                .get("url")
856                .and_then(Value::as_str)
857                .is_some_and(|value| eligible_source(value, artist, organizers))
858        }) {
859            steps.push(Mutation::Update {entity:"event_source".into(),filter:Predicate::All(vec![Predicate::Equal("id".into(),source["id"].clone()),Predicate::Equal("url".into(),source["url"].clone()),Predicate::Equal("last_status".into(),source["last_status"].clone())]),values:literals(json!({"enabled":false,"last_status":"source_removed","last_message":"Not a configured artist website, social profile or active organizer site"})),expected_rows:Some(1)});
860        }
861        for name in PROFILE_FIELDS
862            .into_iter()
863            .filter(|name| *name != "source_url")
864        {
865            let Some(url) = artist
866                .get(name)
867                .and_then(Value::as_str)
868                .and_then(public_link)
869                .filter(|url| url.len() <= 512 && eligible_source(url, artist, organizers))
870            else {
871                continue;
872            };
873            steps.push(Mutation::InsertIfAbsent {entity:"event_source".into(),unique_key:vec!["artist_row_id".into(),"url".into()],values:literals(json!({"artist_row_id":next,"url":url,"enabled":true,"managed":true,"next_check_at":current_timestamp_utc(),"last_status":"pending","last_checked_at":"","last_message":""}))});
874        }
875    }
876    if let Err(error) = store.adapter.transact(
877        &store.config,
878        &store.schema,
879        &Transaction {
880            steps,
881            idempotency_key: None,
882        },
883    ) {
884        eprintln!("event source synchronization failed: {error:?}");
885        return false;
886    }
887    next != 0
888}
889
890/// Fetches one public HTTPS document with bounded response bytes and no redirects.
891fn fetch(url: &str) -> Option<pharos_runtime_integrations::webhook::WebhookResponse> {
892    let mut target = super::outbound_url::https_url(url)?;
893    target.set_fragment(None);
894    pharos_runtime_integrations::webhook::send_request(
895        "GET",
896        target.as_str(),
897        None,
898        "",
899        &[
900            ("User-Agent", "PharosEventDiscovery/0.8.4".to_owned()),
901            (
902                "Accept",
903                "application/ld+json, application/json, text/calendar, text/html".to_owned(),
904            ),
905        ],
906        pharos_runtime_integrations::webhook::WebhookLimits {
907            connect_timeout: Duration::from_secs(3),
908            operation_timeout: Duration::from_secs(8),
909            max_response_bytes: 1024 * 1024,
910            allow_plaintext_local: false,
911        },
912    )
913}
914
915/// Applies conservative public robots rules before reading an explicitly configured page.
916/// Redirects or unavailable policies fail closed; a missing robots document permits access.
917fn robots_allowed(url: &str) -> bool {
918    let Some(parsed) = super::outbound_url::https_url(url) else {
919        return false;
920    };
921    let Ok(robots) = parsed.join("/robots.txt") else {
922        return false;
923    };
924    let Some(response) = fetch(robots.as_str()) else {
925        return false;
926    };
927    if response.status_code == 404 {
928        return true;
929    }
930    if response.status_code != 200 || response.body.len() > 65536 {
931        return false;
932    }
933    let path = parsed.query().map_or_else(
934        || parsed.path().to_owned(),
935        |query| format!("{}?{query}", parsed.path()),
936    );
937    robots_body_allows(&path, &response.body)
938}
939
940/// Evaluates bounded literal robots rules; unsupported encoded or wildcard policies fail closed.
941/// This intentionally supports a narrower public-source policy than a general web crawler.
942fn robots_body_allows(path: &str, body: &str) -> bool {
943    if path.contains('%') || body.len() > 65536 || body.lines().count() > 2000 {
944        return false;
945    }
946    let mut applies = false;
947    let mut in_rules = false;
948    let mut allow = 0;
949    let mut deny = 0;
950    for line in body.lines() {
951        let line = line.split('#').next().unwrap_or_default().trim();
952        let Some((field, value)) = line.split_once(':') else {
953            continue;
954        };
955        let value = value.trim();
956        match field.trim().to_ascii_lowercase().as_str() {
957            "user-agent" => {
958                if in_rules {
959                    applies = false;
960                    in_rules = false;
961                }
962                applies |= value == "*"
963                    || value
964                        .to_ascii_lowercase()
965                        .starts_with("pharoseventdiscovery");
966            }
967            kind @ ("allow" | "disallow") => {
968                in_rules = true;
969                if !applies || value.is_empty() {
970                    continue;
971                }
972                if value.contains(['*', '$', '%']) {
973                    return false;
974                }
975                let prefix = value;
976                if path.starts_with(prefix) {
977                    if kind == "allow" {
978                        allow = allow.max(prefix.len());
979                    } else {
980                        deny = deny.max(prefix.len());
981                    }
982                }
983            }
984            "crawl-delay" | "request-rate" if applies => return false,
985            _ => {}
986        }
987    }
988    deny == 0 || allow >= deny
989}
990
991/// Distinguishes a source policy refusal from a bounded transport failure in owner feedback.
992#[derive(Debug, Clone, Copy)]
993enum SourceReadError {
994    AccessBlocked,
995    Unavailable,
996}
997
998/// Claims at most one due source per poll and records actual fetch/acceptance outcomes.
999pub(super) fn process_one_source(app: &AppRuntime) {
1000    if !app.queue_enabled
1001        || dynamic::dynamic_contract_doc(app, "event_discovery_contract")
1002            .and_then(|value| value.get("enabled").and_then(Value::as_bool))
1003            != Some(true)
1004    {
1005        return;
1006    }
1007    let Some(store) = app_database_operations(app) else {
1008        return;
1009    };
1010    process_store_source_with_app(Some(app), &store, |url| {
1011        if robots_allowed(url) {
1012            fetch(url).ok_or(SourceReadError::Unavailable)
1013        } else {
1014            Err(SourceReadError::AccessBlocked)
1015        }
1016    });
1017}
1018
1019/// Claims and accepts one source using the generic store; tests can supply recorded public responses.
1020#[cfg(test)]
1021fn process_store_source(
1022    store: &super::database::LogicalAppStore,
1023    read: impl FnOnce(
1024        &str,
1025    )
1026        -> Result<pharos_runtime_integrations::webhook::WebhookResponse, SourceReadError>,
1027) {
1028    process_store_source_with_app(None, store, read);
1029}
1030
1031/// Shares durable source acceptance between public-page tests and authorized native consumers.
1032fn process_store_source_with_app(
1033    app: Option<&AppRuntime>,
1034    store: &super::database::LogicalAppStore,
1035    read: impl FnOnce(
1036        &str,
1037    )
1038        -> Result<pharos_runtime_integrations::webhook::WebhookResponse, SourceReadError>,
1039) {
1040    let Ok(approved_organizers) = organizer_sites(store) else {
1041        return;
1042    };
1043    for _ in 0..16 {
1044        if !synchronize_one_artist(store, &approved_organizers) {
1045            break;
1046        }
1047    }
1048    let now = current_timestamp_utc();
1049    if prune_social_reviews(store, runtime_timestamp_seconds(&now).unwrap_or(u64::MAX)).is_err() {
1050        return;
1051    }
1052    let due = Predicate::All(vec![
1053        Predicate::Equal("enabled".to_owned(), json!(true)),
1054        Predicate::AtMost("next_check_at".to_owned(), json!(now)),
1055    ]);
1056    let Ok(rows) = store.adapter.query(
1057        &store.config,
1058        &store.schema,
1059        &Query {
1060            entity: "event_source".to_owned(),
1061            fields: vec![
1062                "id".to_owned(),
1063                "artist_row_id".to_owned(),
1064                "url".to_owned(),
1065                "next_check_at".to_owned(),
1066                "managed".to_owned(),
1067            ],
1068            filter: Some(due),
1069            order: vec![SortKey {
1070                field: "next_check_at".to_owned(),
1071                descending: false,
1072            }],
1073            limit: 1,
1074            offset: 0,
1075        },
1076    ) else {
1077        return;
1078    };
1079    let Some(source) = rows.first() else {
1080        return;
1081    };
1082    let (Some(id), Some(artist_id), Some(url)) = (
1083        source.get("id").and_then(Value::as_i64),
1084        source.get("artist_row_id").and_then(Value::as_i64),
1085        source.get("url").and_then(Value::as_str),
1086    ) else {
1087        return;
1088    };
1089    let source_filter = Predicate::All(vec![
1090        Predicate::Equal("id".into(), json!(id)),
1091        Predicate::Equal("next_check_at".into(), source["next_check_at"].clone()),
1092        Predicate::Equal("enabled".into(), json!(true)),
1093    ]);
1094    let lease = pharos_runtime_core::time::timestamp_after_seconds_utc(120);
1095    if store.adapter.transact(&store.config, &store.schema, &Transaction {steps:vec![Mutation::Update {entity:"event_source".into(),filter:source_filter,values:literals(json!({"next_check_at":lease,"last_status":"running","last_checked_at":now})),expected_rows:Some(1)}],idempotency_key:None}).is_err() { return; }
1096    let artist_record = query(
1097        store,
1098        "artists",
1099        &std::iter::once("name")
1100            .chain(["crm_status", "record_updated_at"])
1101            .chain(PROFILE_FIELDS)
1102            .collect::<Vec<_>>(),
1103        Predicate::Equal("csv_row_id".into(), json!(artist_id)),
1104    );
1105    let artist = artist_record
1106        .as_ref()
1107        .filter(|row| row.get("crm_status").and_then(Value::as_str) == Some("active"))
1108        .and_then(|row| row.get("name"))
1109        .and_then(Value::as_str)
1110        .filter(|name| !name.trim().is_empty())
1111        .map(str::to_owned);
1112    let removed = artist.is_none()
1113        || !artist_record
1114            .as_ref()
1115            .is_some_and(|record| eligible_source(url, record, &approved_organizers));
1116    let parsed_url = super::outbound_url::https_url(url);
1117    let social_authorization = parsed_url
1118        .as_ref()
1119        .is_some_and(requires_social_authorization);
1120    let allowed = !removed && artist.is_some() && !social_authorization && parsed_url.is_some();
1121    let response = if allowed {
1122        read(url)
1123    } else {
1124        Err(SourceReadError::AccessBlocked)
1125    };
1126    let social_result = if social_authorization && !removed {
1127        app.map(|app| {
1128            queue_social_links(
1129                app,
1130                store,
1131                url,
1132                artist_id,
1133                Predicate::All(vec![
1134                    Predicate::Equal("id".into(), json!(id)),
1135                    Predicate::Equal("next_check_at".into(), json!(lease)),
1136                    Predicate::Equal("url".into(), json!(url)),
1137                    Predicate::Equal("enabled".into(), json!(true)),
1138                ]),
1139                artist_record
1140                    .as_ref()
1141                    .and_then(|row| row.get("record_updated_at"))
1142                    .cloned()
1143                    .unwrap_or(Value::Null),
1144            )
1145        })
1146    } else {
1147        None
1148    };
1149    let supports_review = store.schema.entities.iter().any(|entity| {
1150        entity.name == "event_observation"
1151            && entity
1152                .fields
1153                .iter()
1154                .any(|field| field.name == "review_status")
1155    });
1156    let mut reviews = Vec::new();
1157    let (mut status, mut detail, events) = match (response, artist) {
1158        (Ok(response), _) if response.body.len() > 1024 * 1024 => (
1159            "failed", "Source response exceeded the intake limit".to_owned(), vec![],
1160        ),
1161        (Ok(response), Some(artist)) if response.status_code == 200 => {
1162            let epoch = runtime_timestamp_seconds(&now).unwrap_or(u64::MAX);
1163            let extracted = if response.body.trim_start().starts_with("BEGIN:VCALENDAR") {
1164                calendar_feed::extract(&response.body, &artist, url, epoch).map(|events|(events,Vec::new()))
1165            } else {
1166                Ok(extract_events_with_review(&response.body, &artist, url, epoch))
1167            };
1168            match extracted {
1169                Ok((events,unaccepted)) => {
1170                    reviews=unaccepted;
1171                    if events.is_empty() && reviews.is_empty() && password_gate(&response.body) {
1172                        ("authorization_required","Source returned a login page rather than an event feed".into(),Vec::new())
1173                    } else {
1174                    (
1175                    if !reviews.is_empty() {"review"} else if events.is_empty() { "no_events" } else { "success" },
1176                    format!("{} verified structured events; {} candidates need owner review", events.len(),reviews.len()),
1177                    events,
1178                )}},
1179                Err(detail) => ("unsupported_source", detail.to_owned(), vec![]),
1180            }
1181        }
1182        (Ok(response), _) if matches!(response.status_code,404|410) => (
1183            "not_found", "Source reports that the account or page does not exist or has been removed".to_owned(), vec![],
1184        ),
1185        (Ok(response), _) if matches!(response.status_code, 401 | 403) => (
1186            "authorization_required",
1187            "Source requires provider authorization".to_owned(),
1188            vec![],
1189        ),
1190        (Ok(response), _) if response.status_code == 429 => (
1191            "rate_limited",
1192            "Source rate limited this request".to_owned(),
1193            vec![],
1194        ),
1195        (Ok(response), _) => (
1196            "failed",
1197            format!("Source returned HTTP {}", response.status_code),
1198            vec![],
1199        ),
1200        _ if removed => (
1201            "source_removed",
1202            "Source is not a configured artist website, social profile or active organizer site".to_owned(),
1203            vec![],
1204        ),
1205        _ if social_result.as_ref().is_some_and(|result|result.as_ref().is_ok_and(|intake|intake.unverified_posts>0 && intake.verified_posts==0)) => (
1206            "unsupported_source",
1207            "Posts were visible, but their artist attribution or actual posting dates could not be verified. Unverified posts were excluded; no events were inferred from feed text".to_owned(),
1208            vec![],
1209        ),
1210        _ if social_result.as_ref().is_some_and(Result::is_ok) => {
1211            let intake = social_result.as_ref().and_then(|result| result.as_ref().ok());
1212            let events = intake.map(|intake| intake.events.clone()).unwrap_or_default();
1213            (if events.is_empty() {"social_collected"} else {"success"},
1214            format!("{} verified artist performances; {} public source links retained; {} posts have verified posting dates; partial browser view, not a complete feed",events.len(),intake.map_or(0,|intake|intake.link_count),intake.map_or(0,|intake|intake.verified_posts)), events)
1215        },
1216        _ if social_result.as_ref().is_some_and(|result|matches!(result,Err(pharos_runtime_social::SocialError::Busy))) => (
1217            "pending",
1218            "Authorized social collection is queued".into(),
1219            vec![],
1220        ),
1221        _ if social_result.as_ref().is_some_and(|result|matches!(result,Err(pharos_runtime_social::SocialError::RateLimited))) => (
1222            "rate_limited",
1223            "Provider rate limit reached; collection will retry".into(),
1224            vec![],
1225        ),
1226        _ if social_result.as_ref().is_some_and(|result|matches!(result,Err(error) if !matches!(error,pharos_runtime_social::SocialError::AuthorizationRequired|pharos_runtime_social::SocialError::UnsupportedProvider|pharos_runtime_social::SocialError::Forbidden))) => (
1227            "failed",
1228            social_result.as_ref().and_then(|result|result.as_ref().err()).map(ToString::to_string).unwrap_or_default(),
1229            vec![],
1230        ),
1231        _ if social_result.as_ref().is_some_and(|result|matches!(result,Err(pharos_runtime_social::SocialError::UnsupportedProvider))) => (
1232            "connector_unavailable",
1233            pharos_runtime_social::SocialError::UnsupportedProvider.to_string(),
1234            vec![],
1235        ),
1236        _ if social_authorization => (
1237            "authorization_required",
1238            "This social source needs an authorized provider connection".to_owned(),
1239            vec![],
1240        ),
1241        (Err(SourceReadError::AccessBlocked), _) => (
1242            "access_blocked",
1243            "Public crawler access was not permitted or its robots policy could not be verified. The page was not fetched; this does not establish whether the account or page exists".to_owned(),
1244            vec![],
1245        ),
1246        _ => (
1247            "failed",
1248            "Source could not be read safely within the fetch limits".to_owned(),
1249            vec![],
1250        ),
1251    };
1252    if let Some(Ok(intake)) = &social_result {
1253        reviews.extend(intake.reviews.clone());
1254    }
1255    let Ok(existing_events) = model::existing_events(store, &events) else {
1256        return;
1257    };
1258    let mut accepted = Vec::new();
1259    for event in events {
1260        if let Some(existing) = existing_events.get(&event.key)
1261            && model::conflicts(existing, &event, id)
1262        {
1263            let Ok(facts) = serde_json::from_str(&event.evidence) else {
1264                return;
1265            };
1266            reviews.push(ReviewCandidate {facts,reason:"Conflicts with an accepted event; compare retained evidence before updating it",accepted_event_id:existing.get("id").and_then(Value::as_i64)});
1267        } else {
1268            accepted.push(event);
1269        }
1270    }
1271    let events = accepted;
1272    if !reviews.is_empty() {
1273        status = "review";
1274    }
1275    let review_required = !reviews.is_empty();
1276    let reviews = if supports_review {
1277        let Ok(pending) = pending_review_evidence(store, id, reviews) else {
1278            return;
1279        };
1280        pending
1281    } else {
1282        Vec::new()
1283    };
1284    if status == "review" {
1285        status = if review_required && !supports_review {
1286            "unsupported_source"
1287        } else if reviews.is_empty() {
1288            if events.is_empty() {
1289                if review_required {
1290                    "reviewed"
1291                } else {
1292                    "no_events"
1293                }
1294            } else {
1295                "success"
1296            }
1297        } else {
1298            "review"
1299        };
1300        detail = if review_required && !supports_review {
1301            "Source needs owner review; this app has no review contract".into()
1302        } else {
1303            format!(
1304                "{} verified structured events; {} candidates need owner review",
1305                events.len(),
1306                reviews.len()
1307            )
1308        };
1309    }
1310    let Ok(organizers) = (if events.iter().any(|event| !event.organizers.is_empty()) {
1311        model::known_organizers(store)
1312    } else {
1313        Ok(BTreeMap::new())
1314    }) else {
1315        return;
1316    };
1317    let mut common_guards = vec![Mutation::RequireCount {
1318        entity: "event_source".into(),
1319        filter: Predicate::All(vec![
1320            Predicate::Equal("id".into(), json!(id)),
1321            Predicate::Equal("next_check_at".into(), json!(lease)),
1322            Predicate::Equal("url".into(), json!(url)),
1323            Predicate::Equal("enabled".into(), json!(true)),
1324        ]),
1325        minimum: 1,
1326        maximum: Some(1),
1327    }];
1328    if let Some(Ok(intake)) = &social_result {
1329        common_guards.extend(intake.guards.clone());
1330    }
1331    if !events.is_empty() || !reviews.is_empty() {
1332        let Some(artist) = &artist_record else {
1333            return;
1334        };
1335        common_guards.push(Mutation::RequireCount {
1336            entity: "artists".into(),
1337            filter: Predicate::All(vec![
1338                Predicate::Equal("csv_row_id".into(), json!(artist_id)),
1339                Predicate::Equal("crm_status".into(), json!("active")),
1340                Predicate::Equal(
1341                    "record_updated_at".into(),
1342                    artist["record_updated_at"].clone(),
1343                ),
1344            ]),
1345            minimum: 1,
1346            maximum: Some(1),
1347        });
1348    }
1349    let mut groups = Vec::new();
1350    if supports_review {
1351        for (facts, evidence_hash) in reviews {
1352            let Ok(event_key) =
1353                pharos_runtime_database::operations::operation_fingerprint(&(url, &evidence_hash))
1354            else {
1355                continue;
1356            };
1357            groups.push(vec![Mutation::InsertIfAbsent {entity:"event_observation".into(),unique_key:vec!["source_id".into(),"evidence_hash".into()],values:literals(json!({"source_id":id,"event_key":event_key,"evidence_hash":evidence_hash,"source_url":url,"observed_at":now,"facts":facts,"review_status":"review"}))}]);
1358        }
1359    }
1360    let Some(event_entity) = store
1361        .schema
1362        .entities
1363        .iter()
1364        .find(|entity| entity.name == "gig_event")
1365    else {
1366        return;
1367    };
1368    for event in events {
1369        let mut mutations = Vec::new();
1370        let evidence_hash =
1371            pharos_runtime_database::operations::operation_fingerprint(&event.evidence)
1372                .unwrap_or_default();
1373        if evidence_hash.is_empty() {
1374            continue;
1375        }
1376        let mut observation = json!({"source_id":id,"event_key":event.key,"evidence_hash":evidence_hash,"source_url":event.evidence_source,"observed_at":now,"facts":event.evidence});
1377        if supports_review {
1378            observation["review_status"] = json!("accepted");
1379        }
1380        mutations.push(Mutation::InsertIfAbsent {
1381            entity: "event_observation".into(),
1382            unique_key: vec!["source_id".into(), "evidence_hash".into()],
1383            values: literals(observation),
1384        });
1385        let existing = existing_events.get(&event.key);
1386        if existing
1387            .as_ref()
1388            .is_some_and(|row| row.get("manual_locked").and_then(Value::as_bool) == Some(true))
1389        {
1390            groups.push(mutations);
1391            continue;
1392        }
1393        let filter = if let Some(existing) = existing {
1394            Predicate::All(
1395                existing
1396                    .iter()
1397                    .map(|(name, value)| Predicate::Equal(name.clone(), value.clone()))
1398                    .collect(),
1399            )
1400        } else {
1401            Predicate::Equal("event_key".into(), json!(event.key))
1402        };
1403        mutations.push(Mutation::RequireCount {
1404            entity: "gig_event".into(),
1405            filter,
1406            minimum: u64::from(existing.is_some()),
1407            maximum: Some(u64::from(existing.is_some())),
1408        });
1409        let matched_organizers = event
1410            .organizers
1411            .iter()
1412            .filter_map(|name| organizers.get(&normalized_name(name)))
1413            .collect::<Vec<_>>();
1414        let Some(mut values) = model::write_values(
1415            existing,
1416            &event,
1417            artist_id,
1418            id,
1419            &now,
1420            matched_organizers
1421                .first()
1422                .and_then(|row| row.get("id"))
1423                .and_then(Value::as_i64),
1424        ) else {
1425            return;
1426        };
1427        values.retain(|name, _| event_entity.fields.iter().any(|field| field.name == *name));
1428        mutations.push(Mutation::Upsert {
1429            entity: "gig_event".into(),
1430            unique_key: vec!["event_key".into()],
1431            values: literals(Value::Object(values)),
1432        });
1433        mutations.push(Mutation::InsertIfAbsent {
1434            entity: "gig_artist".into(),
1435            unique_key: vec!["artist_row_id".into(), "event_key".into()],
1436            values: literals(json!({"artist_row_id":artist_id,"event_key":event.key})),
1437        });
1438        for organizer in matched_organizers {
1439            mutations.push(Mutation::RequireCount {
1440                entity: "organizer".into(),
1441                filter: Predicate::All(
1442                    organizer
1443                        .iter()
1444                        .map(|(name, value)| Predicate::Equal(name.clone(), value.clone()))
1445                        .collect(),
1446                ),
1447                minimum: 1,
1448                maximum: Some(1),
1449            });
1450            mutations.push(Mutation::InsertIfAbsent {
1451                entity: "gig_organizer".into(),
1452                unique_key: vec!["event_key".into(), "organizer_id".into()],
1453                values: literals(json!({"event_key":event.key,"organizer_id":organizer["id"]})),
1454            });
1455        }
1456        groups.push(mutations);
1457    }
1458    let mut source_values = json!({"next_check_at":pharos_runtime_core::time::timestamp_after_seconds_utc(if status=="pending" {30} else {SOURCE_CHECK_INTERVAL_SECONDS}),"last_status":status,"last_message":detail,"last_checked_at":now,"enabled":!removed});
1459    if matches!(
1460        status,
1461        "success" | "no_events" | "review" | "reviewed" | "social_collected"
1462    ) && store.schema.entities.iter().any(|entity| {
1463        entity.name == "event_source"
1464            && entity
1465                .fields
1466                .iter()
1467                .any(|field| field.name == "last_success_at")
1468    }) {
1469        source_values["last_success_at"] = json!(now);
1470    }
1471    let completion = Mutation::Update {
1472        entity: "event_source".into(),
1473        filter: Predicate::Equal("id".into(), json!(id)),
1474        values: literals(source_values),
1475        expected_rows: Some(1),
1476    };
1477    if let Err(error) = commit_event_groups(store, &common_guards, groups, completion) {
1478        eprintln!("event discovery acceptance failed: {error:?}");
1479        // A rejected event batch must not leave the source looking indefinitely queued.
1480        let mut steps = common_guards;
1481        steps.push(Mutation::Update {entity:"event_source".into(),filter:Predicate::Equal("id".into(),json!(id)),values:literals(json!({"last_status":"failed","last_message":"Event evidence could not be saved; no unvalidated performance was published","last_checked_at":now,"next_check_at":pharos_runtime_core::time::timestamp_after_seconds_utc(SOURCE_CHECK_INTERVAL_SECONDS)})),expected_rows:Some(1)});
1482        if let Err(problem) = store.adapter.transact(
1483            &store.config,
1484            &store.schema,
1485            &Transaction {
1486                steps,
1487                idempotency_key: None,
1488            },
1489        ) {
1490            eprintln!("event discovery failure status unavailable: {problem:?}");
1491        }
1492    }
1493}
1494
1495/// Commits independent event groups below the shared transaction budget, retaining the lease until completion.
1496fn commit_event_groups(
1497    store: &super::database::LogicalAppStore,
1498    guards: &[Mutation],
1499    groups: Vec<Vec<Mutation>>,
1500    completion: Mutation,
1501) -> Result<(), pharos_runtime_database::operations::DatabaseError> {
1502    if groups
1503        .iter()
1504        .any(|group| group.len() + guards.len() + 1 > 200)
1505    {
1506        return Err(pharos_runtime_database::operations::DatabaseError::InvalidInput);
1507    }
1508    let mut steps = guards.to_vec();
1509    for group in groups {
1510        if steps.len() + group.len() + 1 > 200 {
1511            store.adapter.transact(
1512                &store.config,
1513                &store.schema,
1514                &Transaction {
1515                    steps: std::mem::replace(&mut steps, guards.to_vec()),
1516                    idempotency_key: None,
1517                },
1518            )?;
1519        }
1520        steps.extend(group);
1521    }
1522    steps.push(completion);
1523    store.adapter.transact(
1524        &store.config,
1525        &store.schema,
1526        &Transaction {
1527            steps,
1528            idempotency_key: None,
1529        },
1530    )?;
1531    Ok(())
1532}
1533
1534/// Separates validated public-source work from social evidence requiring explicit publication review.
1535struct SocialIntake {
1536    link_count: usize,
1537    unverified_posts: usize,
1538    verified_posts: usize,
1539    events: Vec<DiscoveredEvent>,
1540    reviews: Vec<ReviewCandidate>,
1541    guards: Vec<Mutation>,
1542}
1543
1544/// Keeps only bounded event declarations from inert social evidence; graph metadata and unrelated posts are excluded.
1545fn social_review_candidates(value: &Value, depth: usize, output: &mut Vec<ReviewCandidate>) {
1546    let now = runtime_timestamp_seconds(&current_timestamp_utc()).unwrap_or(u64::MAX);
1547    if depth > 12 || output.len() >= 40 {
1548        return;
1549    }
1550    if value.get("evidence_kind").and_then(Value::as_str) == Some("rendered_link") {
1551        let event_link = value
1552            .get("url")
1553            .and_then(Value::as_str)
1554            .and_then(public_link)
1555            .and_then(|value| url::Url::parse(&value).ok())
1556            .is_some_and(|url| {
1557                url.path_segments().is_some_and(|mut segments| {
1558                    segments.any(|segment| {
1559                        matches!(
1560                            segment.to_ascii_lowercase().as_str(),
1561                            "ticket"
1562                                | "tickets"
1563                                | "event"
1564                                | "events"
1565                                | "festival"
1566                                | "festivals"
1567                                | "gig"
1568                                | "gigs"
1569                        )
1570                    })
1571                })
1572            });
1573        if event_link && social_evidence::eligible(value, now) && value.to_string().len() <= 7680 {
1574            let mut facts = value.clone();
1575            facts["@type"] = json!("Event");
1576            output.push(ReviewCandidate {facts,reason:"Event or ticket link observed on the authorized artist page; verify event facts and public publication rights before publishing",accepted_event_id:None});
1577        }
1578        return;
1579    }
1580    if value.get("evidence_kind").and_then(Value::as_str) == Some("rendered_post") {
1581        let text = format!(
1582            "{} {}",
1583            value
1584                .get("description")
1585                .and_then(Value::as_str)
1586                .unwrap_or(""),
1587            value
1588                .get("image_text")
1589                .and_then(Value::as_str)
1590                .unwrap_or("")
1591        )
1592        .to_lowercase();
1593        if [
1594            "event",
1595            "festival",
1596            "gig",
1597            "perform",
1598            "playing",
1599            "live set",
1600            "ticket",
1601            "concert",
1602            "tour",
1603            "出演",
1604            "イベント",
1605            "フェス",
1606            "ライブ",
1607            "公演",
1608        ]
1609        .iter()
1610        .any(|word| text.contains(word))
1611            && social_evidence::eligible(value, now)
1612            && value.to_string().len() <= 7680
1613        {
1614            let mut candidate = value.clone();
1615            candidate["@type"] = json!("Event");
1616            output.push(ReviewCandidate {facts:candidate,reason:"Rendered social post may describe a performance; verify dates, artist, venue and public publication rights before publishing",accepted_event_id:None});
1617        }
1618        return;
1619    }
1620    if value.get("@type").is_some_and(|kind| {
1621        let matches = |kind: &str| {
1622            matches!(
1623                kind.rsplit('/').next(),
1624                Some("Event" | "MusicEvent" | "Festival")
1625            )
1626        };
1627        kind.as_str().is_some_and(matches)
1628            || kind
1629                .as_array()
1630                .is_some_and(|kinds| kinds.iter().filter_map(Value::as_str).any(matches))
1631    }) && social_evidence::eligible(value, now)
1632        && value.to_string().len() <= 7680
1633    {
1634        output.push(ReviewCandidate {facts:value.clone(),reason:"Authorized social event evidence; verify dates, artist, venue and public publication rights before publishing",accepted_event_id:None});
1635    }
1636    if let Some(values) = value
1637        .as_array()
1638        .or_else(|| value.get("@graph").and_then(Value::as_array))
1639    {
1640        for value in values.iter().take(40) {
1641            social_review_candidates(value, depth + 1, output);
1642        }
1643    }
1644}
1645
1646/// Converts authorized post links into public-page work, never interpreting social prose as event facts.
1647#[cfg(not(target_os = "wasi"))]
1648fn queue_social_links(
1649    app: &AppRuntime,
1650    store: &super::database::LogicalAppStore,
1651    profile: &str,
1652    artist: i64,
1653    source_guard: Predicate,
1654    artist_revision: Value,
1655) -> Result<SocialIntake, pharos_runtime_social::SocialError> {
1656    let observations = super::social_connections::observations_for_profile(
1657        app,
1658        profile,
1659        SOURCE_CHECK_INTERVAL_SECONDS as i64,
1660    )?;
1661    let mut reviews = Vec::new();
1662    let artist_name = query(
1663        store,
1664        "artists",
1665        &["name"],
1666        Predicate::Equal("csv_row_id".into(), json!(artist)),
1667    )
1668    .and_then(|row| row.get("name").and_then(Value::as_str).map(str::to_owned))
1669    .ok_or(pharos_runtime_social::SocialError::InvalidResponse)?;
1670    let now = runtime_timestamp_seconds(&current_timestamp_utc()).unwrap_or(u64::MAX);
1671    let mut events = BTreeMap::new();
1672    for value in observations.evidence.iter().take(40) {
1673        if let Some(mut event) = social_announcements::extract(value, &artist_name, now) {
1674            event.key =
1675                pharos_runtime_database::operations::operation_fingerprint(&(artist, &event.key))
1676                    .map_err(|_| pharos_runtime_social::SocialError::InvalidResponse)?;
1677            events.insert(event.key.clone(), event);
1678        } else {
1679            social_review_candidates(value, 0, &mut reviews);
1680        }
1681    }
1682    let mut events = events.into_values().collect::<Vec<_>>();
1683    social_announcements::retain_existing_identities(store, artist, &mut events)?;
1684    let guards = observations.guards.clone();
1685    let unverified_posts = observations.unverified_posts;
1686    let verified_posts = observations.verified_posts;
1687    let link_count =
1688        accept_social_links(store, artist, source_guard, artist_revision, observations)?;
1689    Ok(SocialIntake {
1690        link_count,
1691        unverified_posts,
1692        verified_posts,
1693        events,
1694        reviews,
1695        guards,
1696    })
1697}
1698
1699/// Commits a retained social link batch under source, artist and connection authority guards.
1700#[cfg(not(target_os = "wasi"))]
1701fn accept_social_links(
1702    store: &super::database::LogicalAppStore,
1703    artist: i64,
1704    source_guard: Predicate,
1705    artist_revision: Value,
1706    observations: super::social_connections::ObservationBatch,
1707) -> Result<usize, pharos_runtime_social::SocialError> {
1708    let fields = std::iter::once("csv_row_id")
1709        .chain(PROFILE_FIELDS)
1710        .collect::<Vec<_>>();
1711    let artist_profile = query(
1712        store,
1713        "artists",
1714        &fields,
1715        Predicate::Equal("csv_row_id".into(), json!(artist)),
1716    )
1717    .ok_or(pharos_runtime_social::SocialError::AuthorizationRequired)?;
1718    let organizers =
1719        organizer_sites(store).map_err(|_| pharos_runtime_social::SocialError::Transport)?;
1720    let mut links = std::collections::BTreeSet::new();
1721    for url in observations.links {
1722        let Some(url) = public_link(&url)
1723            .and_then(|value| url::Url::parse(&value).ok())
1724            .filter(|url| {
1725                !requires_social_authorization(url)
1726                    && eligible_source(url.as_str(), &artist_profile, &organizers)
1727            })
1728        else {
1729            continue;
1730        };
1731        if url.as_str().len() > 512 {
1732            return Err(pharos_runtime_social::SocialError::Capacity);
1733        }
1734        links.insert(url.to_string());
1735    }
1736    if links.len() > 1000 {
1737        return Err(pharos_runtime_social::SocialError::Capacity);
1738    }
1739    let now = current_timestamp_utc();
1740    let mut steps = observations.guards;
1741    steps.push(Mutation::RequireCount {
1742        entity: "event_source".into(),
1743        filter: source_guard,
1744        minimum: 1,
1745        maximum: Some(1),
1746    });
1747    steps.push(Mutation::RequireCount {
1748        entity: "artists".into(),
1749        filter: Predicate::All(vec![
1750            Predicate::Equal("csv_row_id".into(), json!(artist)),
1751            Predicate::Equal("crm_status".into(), json!("active")),
1752            Predicate::Equal("record_updated_at".into(), artist_revision),
1753        ]),
1754        minimum: 1,
1755        maximum: Some(1),
1756    });
1757    for url in &links {
1758        steps.push(Mutation::InsertIfAbsent {entity:"event_source".into(),unique_key:vec!["artist_row_id".into(),"url".into()],values:literals(json!({"artist_row_id":artist,"url":url,"enabled":true,"managed":false,"next_check_at":now,"last_status":"pending","last_checked_at":"","last_message":"From authorized social content; public evidence validation pending"}))});
1759    }
1760    store
1761        .adapter
1762        .transact(
1763            &store.config,
1764            &store.schema,
1765            &Transaction {
1766                steps,
1767                idempotency_key: None,
1768            },
1769        )
1770        .map_err(|_| pharos_runtime_social::SocialError::Transport)?;
1771    Ok(links.len())
1772}
1773
1774/// Keeps unsupported host targets explicitly unavailable rather than attempting unprotected page collection.
1775#[cfg(target_os = "wasi")]
1776fn queue_social_links(
1777    _app: &AppRuntime,
1778    _store: &super::database::LogicalAppStore,
1779    _profile: &str,
1780    _artist: i64,
1781    _source_guard: Predicate,
1782    _artist_revision: Value,
1783) -> Result<SocialIntake, pharos_runtime_social::SocialError> {
1784    Err(pharos_runtime_social::SocialError::UnsupportedProvider)
1785}
1786
1787/// Requeues authorization-blocked sources after an accepted connection, retaining source and connection race guards.
1788pub(super) fn social_connection_available(
1789    app: &AppRuntime,
1790    provider: pharos_runtime_social::provider::SocialProvider,
1791    guards: &[Mutation],
1792) -> Result<(), pharos_runtime_social::SocialError> {
1793    use pharos_runtime_social::SocialError;
1794    if dynamic::dynamic_contract_doc(app, "event_discovery_contract")
1795        .and_then(|value| value.get("enabled").and_then(Value::as_bool))
1796        != Some(true)
1797    {
1798        return Ok(());
1799    }
1800    let store = app_database_operations(app).ok_or(SocialError::Configuration)?;
1801    let rows = store
1802        .adapter
1803        .query(
1804            &store.config,
1805            &store.schema,
1806            &Query {
1807                entity: "event_source".into(),
1808                fields: vec![
1809                    "id".into(),
1810                    "url".into(),
1811                    "next_check_at".into(),
1812                    "last_status".into(),
1813                ],
1814                filter: Some(Predicate::All(vec![
1815                    Predicate::Equal("enabled".into(), json!(true)),
1816                    Predicate::In(
1817                        "last_status".into(),
1818                        vec![
1819                            json!("authorization_required"),
1820                            json!("connector_unavailable"),
1821                        ],
1822                    ),
1823                ])),
1824                order: vec![],
1825                limit: 1000,
1826                offset: 0,
1827            },
1828        )
1829        .map_err(|_| SocialError::Transport)?;
1830    let now = current_timestamp_utc();
1831    let mut changes = Vec::new();
1832    for row in rows {
1833        if row
1834            .get("url")
1835            .and_then(Value::as_str)
1836            .and_then(|value| url::Url::parse(value).ok())
1837            .as_ref()
1838            .and_then(pharos_runtime_social::provider::SocialProvider::from_url)
1839            != Some(provider)
1840        {
1841            continue;
1842        }
1843        changes.push(Mutation::Update {entity:"event_source".into(),filter:Predicate::All(vec![Predicate::Equal("id".into(),row["id"].clone()),Predicate::Equal("next_check_at".into(),row["next_check_at"].clone()),Predicate::Equal("last_status".into(),row["last_status"].clone()),Predicate::Equal("enabled".into(),json!(true))]),values:literals(json!({"next_check_at":now,"last_status":"pending","last_message":"Website connection saved; collection queued"})),expected_rows:Some(1)});
1844    }
1845    for batch in changes.chunks(150) {
1846        let mut steps = guards.to_vec();
1847        steps.extend_from_slice(batch);
1848        store
1849            .adapter
1850            .transact(
1851                &store.config,
1852                &store.schema,
1853                &Transaction {
1854                    steps,
1855                    idempotency_key: None,
1856                },
1857            )
1858            .map_err(|_| SocialError::Busy)?;
1859    }
1860    Ok(())
1861}
1862
1863#[cfg(test)]
1864mod tests {
1865    use super::*;
1866
1867    /// Never relaxes a source restriction by truncating patterns or ignoring encoded paths and queries.
1868    #[test]
1869    fn robots_rules_fail_closed_for_unsupported_patterns() {
1870        assert!(robots_body_allows(
1871            "/events",
1872            "User-agent: *\nDisallow: /private"
1873        ));
1874        assert!(!robots_body_allows(
1875            "/private/events",
1876            "User-agent: *\nDisallow: /private"
1877        ));
1878        assert!(robots_body_allows(
1879            "/private/public",
1880            "User-agent: *\nDisallow: /private\nAllow: /private/public"
1881        ));
1882        assert!(!robots_body_allows(
1883            "/private/events",
1884            "User-agent: *\nAllow: /\nDisallow: /*private"
1885        ));
1886        assert!(!robots_body_allows(
1887            "/%70rivate",
1888            "User-agent: *\nDisallow: /private"
1889        ));
1890        assert!(!robots_body_allows(
1891            "/?private=1",
1892            "User-agent: *\nDisallow: /?private"
1893        ));
1894        assert!(!robots_body_allows(
1895            "/events",
1896            "User-agent: *\nCrawl-delay: 60"
1897        ));
1898    }
1899
1900    /// Imported catalog references and arbitrary post links never expand polling authority.
1901    #[test]
1902    fn discovery_sources_stay_on_declared_sites() {
1903        let artist=json!({"website":"https://artist.example/","facebook":"https://www.facebook.com/artist","source_url":"https://label.example/artists/artist"}).as_object().unwrap().clone();
1904        let organizers = vec![url::Url::parse("https://festival.example").unwrap()];
1905        let direct =
1906            json!({"website":"https://artist.example/","source_url":"https://artist.example/"})
1907                .as_object()
1908                .unwrap()
1909                .clone();
1910        assert!(eligible_source(
1911            "https://artist.example/events",
1912            &direct,
1913            &[]
1914        ));
1915        let social_website = json!({"website":"https://facebook.com/artist"})
1916            .as_object()
1917            .unwrap()
1918            .clone();
1919        assert!(!eligible_source(
1920            "https://facebook.com/other",
1921            &social_website,
1922            &[]
1923        ));
1924        for value in [
1925            "https://artist.example/events",
1926            "https://www.artist.example/events",
1927            "https://festival.example/gig",
1928            "https://facebook.com/artist/posts/123",
1929        ] {
1930            assert!(eligible_source(value, &artist, &organizers), "{value}");
1931        }
1932        for value in [
1933            "https://www.bing.com/search?q=artist",
1934            "https://linktr.ee/artist",
1935            "https://label.example/artists/artist?tracking=1",
1936            "https://unrelated.example/event",
1937            "https://facebook.com/anotherartist",
1938            "https://artist.example.evil.test/events",
1939        ] {
1940            assert!(!eligible_source(value, &artist, &organizers), "{value}");
1941        }
1942    }
1943
1944    /// Keeps social login requirements explicit without misclassifying lookalike hosts.
1945    #[test]
1946    fn social_sources_require_authorized_connectors() {
1947        for host in ["facebook.com", "www.instagram.com", "twitter.com", "x.com"] {
1948            assert!(requires_social_authorization(
1949                &url::Url::parse(&format!("https://{host}/artist")).unwrap()
1950            ));
1951        }
1952        for host in ["artist.example", "notfacebook.com", "facebook.com.example"] {
1953            assert!(!requires_social_authorization(
1954                &url::Url::parse(&format!("https://{host}/artist")).unwrap()
1955            ));
1956        }
1957    }
1958
1959    /// Exercises durable discovery on an isolated SQLite database or an operator-selected backend test namespace.
1960    #[test]
1961    #[ignore = "requires PHAROS_CRM_CONTRACT pointing to the staged music catalog contract"]
1962    fn recorded_events_are_idempotent_and_preserve_manual_corrections() {
1963        let path = std::env::var("PHAROS_CRM_CONTRACT").unwrap();
1964        let value =
1965            pharos_runtime_compiler::database_yaml::parse(&std::fs::read_to_string(path).unwrap())
1966                .unwrap();
1967        let contract: pharos_runtime_database::contract::AppDataContract =
1968            serde_json::from_value(value.clone()).unwrap();
1969        let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1970            .join("tmp/0.8.4-testing/music-crm/discovery-tests");
1971        std::fs::create_dir_all(&dir).unwrap();
1972        let db = dir.join(format!(
1973            "events-{}-{}.sqlite3",
1974            std::process::id(),
1975            super::super::current_timestamp_utc().replace([':', '.'], "-")
1976        ));
1977        let backend = std::env::var("PHAROS_CRM_TEST_BACKEND").unwrap_or_else(|_| "sqlite".into());
1978        let mut config = pharos_runtime_database::DbBackendConfig {
1979            backend: backend.clone(),
1980            host: "127.0.0.1".into(),
1981            tls_mode: "disable".into(),
1982            ..Default::default()
1983        };
1984        if backend != "sqlite" {
1985            config.database = std::env::var("PHAROS_CRM_TEST_DATABASE").unwrap();
1986            assert!(
1987                config.database.starts_with("pharos_music_cert_")
1988                    && config.database.len() <= 63
1989                    && config
1990                        .database
1991                        .bytes()
1992                        .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
1993            );
1994        }
1995        match backend.as_str() {
1996            "sqlite" => config.sqlite_path = Some(db.clone()),
1997            "postgresql" => {
1998                config.port = "25432".into();
1999                config.user = std::env::var("PHAROS_CRM_TEST_PG_USER").unwrap();
2000                config.password_env_name = "PHAROS_DB_PARITY_PG_PASSWORD".into();
2001            }
2002            "mysql" => {
2003                config.port = "23306".into();
2004                config.user = "root".into();
2005                config.password_env_name = "PHAROS_TEST_MYSQL_ROOT_PASSWORD".into();
2006            }
2007            "mongodb" => {
2008                config.port = "27018".into();
2009                config.options = Some("directConnection=true".into());
2010            }
2011            _ => panic!("unsupported isolated backend"),
2012        }
2013        let adapter = pharos_runtime_database_adapters::resolve(&config).unwrap();
2014        adapter.ensure_database(&config, &contract.schema).unwrap();
2015        adapter.apply_contract(&config, &contract).unwrap();
2016        let store = super::super::database::LogicalAppStore {
2017            config,
2018            schema: contract.schema,
2019            adapter,
2020        };
2021        // Imported catalog rows are replayed by their historical migrations. Keep this
2022        // isolated test focused on its own artist, without fetching imported profiles.
2023        store
2024            .adapter
2025            .transact(
2026                &store.config,
2027                &store.schema,
2028                &Transaction {
2029                    steps: vec![Mutation::Update {
2030                        entity: "artists".into(),
2031                        filter: Predicate::Equal("crm_status".into(), json!("active")),
2032                        values: literals(json!({"crm_status":"inactive"})),
2033                        expected_rows: None,
2034                    }],
2035                    idempotency_key: None,
2036                },
2037            )
2038            .unwrap();
2039        let mut fields = Map::new();
2040        for field in value["schema"]["entities"]
2041            .as_array()
2042            .unwrap()
2043            .iter()
2044            .find(|e| e["name"] == "artists")
2045            .unwrap()["fields"]
2046            .as_array()
2047            .unwrap()
2048        {
2049            let name = field["name"].as_str().unwrap();
2050            if name == "csv_row_id" || !field["default"].is_null() {
2051                continue;
2052            }
2053            fields.insert(
2054                name.into(),
2055                if field["kind"] == "integer" {
2056                    json!(1)
2057                } else if field["nullable"] == true {
2058                    Value::Null
2059                } else {
2060                    json!("")
2061                },
2062            );
2063        }
2064        fields.insert("name".into(), json!("Mad Maxx"));
2065        fields.insert("website".into(), json!("https://artist.example/events"));
2066        store
2067            .adapter
2068            .transact(
2069                &store.config,
2070                &store.schema,
2071                &Transaction {
2072                    steps: vec![Mutation::Insert {
2073                        entity: "artists".into(),
2074                        values: literals(Value::Object(fields)),
2075                    }],
2076                    idempotency_key: None,
2077                },
2078            )
2079            .unwrap();
2080        let event = json!({"@type":"MusicEvent","name":"Recorded test performance","startDate":pharos_runtime_core::time::timestamp_after_seconds_utc(86400),"location":{"name":"Recorded venue"},"performer":{"name":"Mad Maxx"},"url":"https://events.example/performance","offers":{"url":"https://tickets.example/performance"}});
2081        let run = |event: &Value| {
2082            store
2083                .adapter
2084                .transact(
2085                    &store.config,
2086                    &store.schema,
2087                    &Transaction {
2088                        steps: vec![Mutation::Update {
2089                            entity: "event_source".into(),
2090                            filter: Predicate::Equal("enabled".into(), json!(true)),
2091                            values: literals(json!({"next_check_at":"2020-01-01T00:00:00Z"})),
2092                            expected_rows: None,
2093                        }],
2094                        idempotency_key: None,
2095                    },
2096                )
2097                .unwrap();
2098            process_store_source(&store, |_| {
2099                Ok(pharos_runtime_integrations::webhook::WebhookResponse {
2100                    status_code: 200,
2101                    body: event.to_string(),
2102                })
2103            });
2104        };
2105        // The first poll discovers the artist URL and accepts the recorded response.
2106        run(&event);
2107        run(&event);
2108        let source_state = query(
2109            &store,
2110            "event_source",
2111            &[
2112                "last_status",
2113                "last_message",
2114                "enabled",
2115                "url",
2116                "last_checked_at",
2117                "next_check_at",
2118            ],
2119            Predicate::Equal("id".into(), json!(1)),
2120        );
2121        let state = source_state.as_ref().unwrap();
2122        let checked =
2123            runtime_timestamp_seconds(state["last_checked_at"].as_str().unwrap()).unwrap();
2124        let next = runtime_timestamp_seconds(state["next_check_at"].as_str().unwrap()).unwrap();
2125        assert!(
2126            (43_100..=43_300).contains(&(next - checked)),
2127            "discovery must run twice daily"
2128        );
2129        let gig = query(
2130            &store,
2131            "gig_event",
2132            &["event_key", "title", "status"],
2133            Predicate::Equal("title".into(), json!("Recorded test performance")),
2134        )
2135        .unwrap_or_else(|| panic!("missing accepted gig; source: {source_state:?}"));
2136        let count = store
2137            .adapter
2138            .query(
2139                &store.config,
2140                &store.schema,
2141                &Query {
2142                    entity: "gig_event".into(),
2143                    fields: vec!["id".into()],
2144                    filter: None,
2145                    order: vec![],
2146                    limit: 10,
2147                    offset: 0,
2148                },
2149            )
2150            .unwrap();
2151        assert_eq!(count.len(), 1);
2152        // The same performance discovered through two explicit artist sources keeps one event and both relationships.
2153        let artist_fields = store
2154            .schema
2155            .entities
2156            .iter()
2157            .find(|entity| entity.name == "artists")
2158            .unwrap()
2159            .fields
2160            .iter()
2161            .map(|field| field.name.as_str())
2162            .collect::<Vec<_>>();
2163        let mut second = query(
2164            &store,
2165            "artists",
2166            &artist_fields,
2167            Predicate::All(vec![
2168                Predicate::Equal("name".into(), json!("Mad Maxx")),
2169                Predicate::Equal("crm_status".into(), json!("active")),
2170            ]),
2171        )
2172        .unwrap();
2173        second.remove("csv_row_id");
2174        second.insert(
2175            "id".into(),
2176            if second.get("id").is_some_and(Value::is_string) {
2177                json!("controlled-secondary-artist")
2178            } else {
2179                json!(1000000)
2180            },
2181        );
2182        second.insert("name".into(), json!("Mad Tribe"));
2183        second.insert(
2184            "website".into(),
2185            json!("https://otherartist.example/events"),
2186        );
2187        store
2188            .adapter
2189            .transact(
2190                &store.config,
2191                &store.schema,
2192                &Transaction {
2193                    steps: vec![
2194                        Mutation::Insert {
2195                            entity: "artists".into(),
2196                            values: literals(Value::Object(second)),
2197                        },
2198                        Mutation::Update {
2199                            entity: "event_source".into(),
2200                            filter: Predicate::Equal("id".into(), json!(1)),
2201                            values: literals(json!({"enabled":false})),
2202                            expected_rows: Some(1),
2203                        },
2204                    ],
2205                    idempotency_key: None,
2206                },
2207            )
2208            .unwrap();
2209        let mut shared_event = event.clone();
2210        shared_event["performer"] = json!([{"name":"Mad Maxx"},{"name":"Mad Tribe"}]);
2211        for _ in 0..2 {
2212            process_store_source(&store, |_| {
2213                Ok(pharos_runtime_integrations::webhook::WebhookResponse {
2214                    status_code: 200,
2215                    body: shared_event.to_string(),
2216                })
2217            });
2218        }
2219        let links = store
2220            .adapter
2221            .query(
2222                &store.config,
2223                &store.schema,
2224                &Query {
2225                    entity: "gig_artist".into(),
2226                    fields: vec!["artist_row_id".into()],
2227                    filter: Some(Predicate::Equal(
2228                        "event_key".into(),
2229                        gig["event_key"].clone(),
2230                    )),
2231                    order: vec![],
2232                    limit: 10,
2233                    offset: 0,
2234                },
2235            )
2236            .unwrap();
2237        assert_eq!(links.len(), 2);
2238        store
2239            .adapter
2240            .transact(
2241                &store.config,
2242                &store.schema,
2243                &Transaction {
2244                    steps: vec![
2245                        Mutation::Update {
2246                            entity: "event_source".into(),
2247                            filter: Predicate::Equal(
2248                                "url".into(),
2249                                json!("https://otherartist.example/events"),
2250                            ),
2251                            values: literals(json!({"enabled":false})),
2252                            expected_rows: Some(1),
2253                        },
2254                        Mutation::Update {
2255                            entity: "event_source".into(),
2256                            filter: Predicate::Equal("id".into(), json!(1)),
2257                            values: literals(json!({"enabled":true})),
2258                            expected_rows: Some(1),
2259                        },
2260                    ],
2261                    idempotency_key: None,
2262                },
2263            )
2264            .unwrap();
2265        let mut rescheduled = event.clone();
2266        rescheduled["startDate"] = json!(pharos_runtime_core::time::timestamp_after_seconds_utc(
2267            172800
2268        ));
2269        rescheduled["eventStatus"] = json!("https://schema.org/EventRescheduled");
2270        run(&rescheduled);
2271        assert_eq!(
2272            runtime_timestamp_seconds(
2273                query(
2274                    &store,
2275                    "gig_event",
2276                    &["starts_at"],
2277                    Predicate::Equal("event_key".into(), gig["event_key"].clone())
2278                )
2279                .unwrap()["starts_at"]
2280                    .as_str()
2281                    .unwrap()
2282            ),
2283            runtime_timestamp_seconds(rescheduled["startDate"].as_str().unwrap())
2284        );
2285        // Failed/forbidden/rate-limited reads must preserve the accepted event and report their actual state.
2286        let last_success = query(
2287            &store,
2288            "event_source",
2289            &["last_success_at"],
2290            Predicate::Equal("id".into(), json!(1)),
2291        )
2292        .unwrap()["last_success_at"]
2293            .clone();
2294        assert!(!last_success.as_str().unwrap().is_empty());
2295        for (response, status) in [
2296            (
2297                Ok(pharos_runtime_integrations::webhook::WebhookResponse {
2298                    status_code: 200,
2299                    body: "<html><title>Sign in</title><input type=\"password\"></html>".into(),
2300                }),
2301                "authorization_required",
2302            ),
2303            (
2304                Ok(pharos_runtime_integrations::webhook::WebhookResponse {
2305                    status_code: 429,
2306                    body: String::new(),
2307                }),
2308                "rate_limited",
2309            ),
2310            (
2311                Ok(pharos_runtime_integrations::webhook::WebhookResponse {
2312                    status_code: 403,
2313                    body: String::new(),
2314                }),
2315                "authorization_required",
2316            ),
2317            (
2318                Ok(pharos_runtime_integrations::webhook::WebhookResponse {
2319                    status_code: 503,
2320                    body: String::new(),
2321                }),
2322                "failed",
2323            ),
2324            (
2325                Ok(pharos_runtime_integrations::webhook::WebhookResponse {
2326                    status_code: 200,
2327                    body: "x".repeat(1024 * 1024 + 1),
2328                }),
2329                "failed",
2330            ),
2331            (
2332                Ok(pharos_runtime_integrations::webhook::WebhookResponse {
2333                    status_code: 404,
2334                    body: String::new(),
2335                }),
2336                "not_found",
2337            ),
2338            (
2339                Ok(pharos_runtime_integrations::webhook::WebhookResponse {
2340                    status_code: 410,
2341                    body: String::new(),
2342                }),
2343                "not_found",
2344            ),
2345            (Err(SourceReadError::AccessBlocked), "access_blocked"),
2346        ] {
2347            store
2348                .adapter
2349                .transact(
2350                    &store.config,
2351                    &store.schema,
2352                    &Transaction {
2353                        steps: vec![Mutation::Update {
2354                            entity: "event_source".into(),
2355                            filter: Predicate::Equal("id".into(), json!(1)),
2356                            values: literals(json!({"next_check_at":"2020-01-01T00:00:00Z"})),
2357                            expected_rows: Some(1),
2358                        }],
2359                        idempotency_key: None,
2360                    },
2361                )
2362                .unwrap();
2363            process_store_source(&store, |_| response.clone());
2364            assert_eq!(
2365                query(
2366                    &store,
2367                    "event_source",
2368                    &["last_success_at"],
2369                    Predicate::Equal("id".into(), json!(1))
2370                )
2371                .unwrap()["last_success_at"],
2372                last_success
2373            );
2374            assert_eq!(
2375                query(
2376                    &store,
2377                    "event_source",
2378                    &["last_status"],
2379                    Predicate::Equal("id".into(), json!(1))
2380                )
2381                .unwrap()["last_status"],
2382                status
2383            );
2384            assert_eq!(
2385                query(
2386                    &store,
2387                    "gig_event",
2388                    &["title"],
2389                    Predicate::Equal("event_key".into(), gig["event_key"].clone())
2390                )
2391                .unwrap()["title"],
2392                event["name"]
2393            );
2394        }
2395        // A reopened store sees the durable accepted event and retained schedule.
2396        let reopened = super::super::database::LogicalAppStore {
2397            adapter: pharos_runtime_database_adapters::resolve(&store.config).unwrap(),
2398            config: store.config.clone(),
2399            schema: store.schema.clone(),
2400        };
2401        assert!(
2402            query(
2403                &reopened,
2404                "gig_event",
2405                &["event_key"],
2406                Predicate::Equal("event_key".into(), gig["event_key"].clone())
2407            )
2408            .is_some()
2409        );
2410        let mut cancelled = event.clone();
2411        cancelled["eventStatus"] = json!("https://schema.org/EventCancelled");
2412        run(&cancelled);
2413        assert_eq!(
2414            query(
2415                &store,
2416                "gig_event",
2417                &["status"],
2418                Predicate::Equal("event_key".into(), gig["event_key"].clone())
2419            )
2420            .unwrap()["status"],
2421            "cancelled"
2422        );
2423        store
2424            .adapter
2425            .transact(
2426                &store.config,
2427                &store.schema,
2428                &Transaction {
2429                    steps: vec![Mutation::Update {
2430                        entity: "gig_event".into(),
2431                        filter: Predicate::Equal("event_key".into(), gig["event_key"].clone()),
2432                        values: literals(json!({"manual_locked":true,"title":"Owner correction"})),
2433                        expected_rows: Some(1),
2434                    }],
2435                    idempotency_key: None,
2436                },
2437            )
2438            .unwrap();
2439        run(&event);
2440        assert_eq!(
2441            query(
2442                &store,
2443                "gig_event",
2444                &["title"],
2445                Predicate::Equal("event_key".into(), gig["event_key"].clone())
2446            )
2447            .unwrap()["title"],
2448            "Owner correction"
2449        );
2450        // Old, retrospective and unattributed social reviews are removed without touching the accepted gig.
2451        for (key, candidate, status) in [
2452            (
2453                "old-post",
2454                json!({"evidence_kind":"rendered_post","datePublished":"Dec 30 2024","description":"Playing October 30 2026"}),
2455                "review",
2456            ),
2457            (
2458                "retrospective",
2459                json!({"evidence_kind":"rendered_post","datePublished":current_timestamp_utc(),"description":"Thank you for the festival"}),
2460                "review",
2461            ),
2462            (
2463                "stream",
2464                json!({"source_profile":"https://facebook.com/artist","description":"private-stream-text"}),
2465                "review",
2466            ),
2467            (
2468                "owner-decision",
2469                json!({"evidence_kind":"rendered_post","description":"owner-rejected-evidence"}),
2470                "rejected",
2471            ),
2472        ] {
2473            store.adapter.transact(&store.config,&store.schema,&Transaction {steps:vec![Mutation::Insert{entity:"event_observation".into(),values:literals(json!({"source_id":1,"event_key":key,"evidence_hash":key,"source_url":"https://facebook.com/artist","observed_at":current_timestamp_utc(),"facts":json!({"candidate":candidate}).to_string(),"review_status":status}))}],idempotency_key:None}).unwrap();
2474        }
2475        prune_social_reviews(
2476            &store,
2477            runtime_timestamp_seconds(&current_timestamp_utc()).unwrap(),
2478        )
2479        .unwrap();
2480        for key in ["old-post", "retrospective", "stream"] {
2481            let row = query(
2482                &store,
2483                "event_observation",
2484                &["facts", "review_status"],
2485                Predicate::Equal("event_key".into(), json!(key)),
2486            )
2487            .unwrap();
2488            assert_eq!(row["review_status"], "rejected");
2489            assert!(
2490                !row["facts"]
2491                    .as_str()
2492                    .unwrap()
2493                    .contains("private-stream-text")
2494            );
2495        }
2496        assert!(
2497            query(
2498                &store,
2499                "event_observation",
2500                &["facts"],
2501                Predicate::Equal("event_key".into(), json!("owner-decision"))
2502            )
2503            .unwrap()["facts"]
2504                .as_str()
2505                .unwrap()
2506                .contains("owner-rejected-evidence")
2507        );
2508        prune_social_reviews(
2509            &store,
2510            runtime_timestamp_seconds(&current_timestamp_utc()).unwrap(),
2511        )
2512        .unwrap();
2513        // Incomplete structured candidates remain private, idempotent and durably rejected across repeat discovery.
2514        assert!(
2515            store
2516                .schema
2517                .entities
2518                .iter()
2519                .any(|entity| entity.name == "event_observation"
2520                    && entity
2521                        .fields
2522                        .iter()
2523                        .any(|field| field.name == "review_status")),
2524            "review schema required for this acceptance case"
2525        );
2526        let mut incomplete = event.clone();
2527        incomplete.as_object_mut().unwrap().remove("startDate");
2528        run(&incomplete);
2529        let candidate = query(
2530            &store,
2531            "event_observation",
2532            &["id", "facts", "review_status"],
2533            Predicate::Equal("review_status".into(), json!("review")),
2534        )
2535        .unwrap();
2536        assert!(
2537            candidate["facts"]
2538                .as_str()
2539                .unwrap()
2540                .contains("extraction_version")
2541        );
2542        store
2543            .adapter
2544            .transact(
2545                &store.config,
2546                &store.schema,
2547                &Transaction {
2548                    steps: vec![Mutation::Update {
2549                        entity: "event_observation".into(),
2550                        filter: Predicate::Equal("id".into(), candidate["id"].clone()),
2551                        values: literals(json!({"review_status":"rejected"})),
2552                        expected_rows: Some(1),
2553                    }],
2554                    idempotency_key: None,
2555                },
2556            )
2557            .unwrap();
2558        run(&incomplete);
2559        assert_eq!(
2560            query(
2561                &store,
2562                "event_observation",
2563                &["review_status"],
2564                Predicate::Equal("id".into(), candidate["id"].clone())
2565            )
2566            .unwrap()["review_status"],
2567            "rejected"
2568        );
2569        assert_eq!(
2570            query(
2571                &store,
2572                "event_source",
2573                &["last_status"],
2574                Predicate::Equal("id".into(), json!(1))
2575            )
2576            .unwrap()["last_status"],
2577            "reviewed"
2578        );
2579        assert!(
2580            query(
2581                &store,
2582                "event_observation",
2583                &["id"],
2584                Predicate::Equal("review_status".into(), json!("review"))
2585            )
2586            .is_none()
2587        );
2588        // Social links use the same public queue and cannot publish unverified post text.
2589        let source_artist = query(
2590            &store,
2591            "event_source",
2592            &["artist_row_id"],
2593            Predicate::Equal("id".into(), json!(1)),
2594        )
2595        .unwrap();
2596        let artist = query(
2597            &store,
2598            "artists",
2599            &["csv_row_id", "record_updated_at"],
2600            Predicate::Equal("csv_row_id".into(), source_artist["artist_row_id"].clone()),
2601        )
2602        .unwrap();
2603        let artist_id = artist["csv_row_id"].as_i64().unwrap();
2604        let scope = pharos_runtime_social::connection::ConnectionScope::new(
2605            &store.schema.app_id,
2606            "controlled_social",
2607            7,
2608        )
2609        .unwrap();
2610        let scope_filter = pharos_runtime_social::repository::scope_filter(
2611            &scope,
2612            pharos_runtime_social::provider::SocialProvider::X,
2613        );
2614        store.adapter.transact(&store.config,&store.schema,&Transaction {steps:vec![Mutation::Insert {entity:"social_connection".into(),values:literals(json!({"application":scope.application(),"namespace":scope.namespace(),"owner":7,"provider":"x","account_id":"123","username":"controlled","registration":"r1","revision":1,"status":"connected","expires":2000000000,"credential_ref":"controlled-reference","lease_token":"","lease_until":0}))}],idempotency_key:None}).unwrap();
2615        let batch = || super::super::social_connections::ObservationBatch {
2616            unverified_posts: 0,
2617            verified_posts: 0,
2618            evidence: Vec::new(),
2619            links: vec![
2620                "https://artist.example/from-social".into(),
2621                "https://artist.example/from-social".into(),
2622                "https://x.com/another-profile".into(),
2623                "https://www.bing.com/search?q=artist".into(),
2624                "https://linktr.ee/artist".into(),
2625                "https://unrelated.example/event".into(),
2626            ],
2627            guards: vec![Mutation::RequireCount {
2628                entity: "social_connection".into(),
2629                filter: Predicate::All(vec![
2630                    scope_filter.clone(),
2631                    Predicate::Equal("revision".into(), json!(1)),
2632                    Predicate::Equal("status".into(), json!("connected")),
2633                ]),
2634                minimum: 1,
2635                maximum: Some(1),
2636            }],
2637        };
2638        let source_guard = Predicate::All(vec![
2639            Predicate::Equal("id".into(), json!(1)),
2640            Predicate::Equal("enabled".into(), json!(true)),
2641        ]);
2642        for _ in 0..2 {
2643            assert_eq!(
2644                accept_social_links(
2645                    &store,
2646                    artist_id,
2647                    source_guard.clone(),
2648                    artist["record_updated_at"].clone(),
2649                    batch()
2650                )
2651                .unwrap(),
2652                1
2653            );
2654        }
2655        let public_sources = store
2656            .adapter
2657            .query(
2658                &store.config,
2659                &store.schema,
2660                &Query {
2661                    entity: "event_source".into(),
2662                    fields: vec!["id".into()],
2663                    filter: Some(Predicate::Equal(
2664                        "url".into(),
2665                        json!("https://artist.example/from-social"),
2666                    )),
2667                    order: vec![],
2668                    limit: 10,
2669                    offset: 0,
2670                },
2671            )
2672            .unwrap();
2673        assert_eq!(public_sources.len(), 1);
2674        store
2675            .adapter
2676            .transact(
2677                &store.config,
2678                &store.schema,
2679                &Transaction {
2680                    steps: vec![Mutation::Update {
2681                        entity: "social_connection".into(),
2682                        filter: scope_filter.clone(),
2683                        values: literals(json!({"status":"disconnected","revision":2})),
2684                        expected_rows: Some(1),
2685                    }],
2686                    idempotency_key: None,
2687                },
2688            )
2689            .unwrap();
2690        assert!(
2691            accept_social_links(
2692                &store,
2693                artist_id,
2694                source_guard,
2695                artist["record_updated_at"].clone(),
2696                batch()
2697            )
2698            .is_err()
2699        );
2700        // A full source page must commit in bounded groups, retaining every event and organizer link.
2701        for name in ["First organizer", "Second organizer"] {
2702            store
2703                .adapter
2704                .transact(
2705                    &store.config,
2706                    &store.schema,
2707                    &Transaction {
2708                        steps: vec![Mutation::Insert {
2709                            entity: "organizer".into(),
2710                            values: literals(json!({"name":name})),
2711                        }],
2712                        idempotency_key: None,
2713                    },
2714                )
2715                .unwrap();
2716        }
2717        let page = Value::Array(
2718            (0..100)
2719                .map(|index| {
2720                    json!({
2721                        "@type":"MusicEvent", "@id":format!("https://events.example/batch/{index}"),
2722                        "name":format!("Batch performance {index}"),
2723                        "startDate":pharos_runtime_core::time::timestamp_after_seconds_utc(172800),
2724                        "location":{"name":"Batch venue"},
2725                        "performer":[{"name":"Mad Maxx"},{"name":"Mad Tribe"}],
2726                        "organizer":[{"name":"First organizer"},{"name":"Second organizer"}],
2727                        "url":format!("https://events.example/batch/{index}")
2728                    })
2729                })
2730                .collect(),
2731        );
2732        run(&page);
2733        run(&page);
2734        let batch_events = store
2735            .adapter
2736            .query(
2737                &store.config,
2738                &store.schema,
2739                &Query {
2740                    entity: "gig_event".into(),
2741                    fields: vec!["event_key".into()],
2742                    filter: Some(Predicate::Contains(
2743                        "title".into(),
2744                        "Batch performance".into(),
2745                    )),
2746                    order: vec![],
2747                    limit: 1000,
2748                    offset: 0,
2749                },
2750            )
2751            .unwrap();
2752        assert_eq!(batch_events.len(), 100);
2753        let links = store
2754            .adapter
2755            .query(
2756                &store.config,
2757                &store.schema,
2758                &Query {
2759                    entity: "gig_organizer".into(),
2760                    fields: vec!["event_key".into()],
2761                    filter: None,
2762                    order: vec![],
2763                    limit: 1000,
2764                    offset: 0,
2765                },
2766            )
2767            .unwrap();
2768        assert_eq!(links.len(), 200);
2769        // Paused sources never reach the injected reader.
2770        store
2771            .adapter
2772            .transact(
2773                &store.config,
2774                &store.schema,
2775                &Transaction {
2776                    steps: vec![Mutation::Update {
2777                        entity: "event_source".into(),
2778                        filter: Predicate::Equal("enabled".into(), json!(true)),
2779                        values: literals(json!({"enabled":false})),
2780                        expected_rows: Some(2),
2781                    }],
2782                    idempotency_key: None,
2783                },
2784            )
2785            .unwrap();
2786        process_store_source(&store, |_| panic!("paused source fetched"));
2787        let mut invalid_steps = vec![Mutation::Update {
2788            entity: "artists".into(),
2789            filter: Predicate::Equal("csv_row_id".into(), json!(artist_id)),
2790            values: literals(json!({"source_url":"https://label.example/artists/artist"})),
2791            expected_rows: Some(1),
2792        }];
2793        for url in [
2794            "https://www.bing.com/search?q=artist",
2795            "https://linktr.ee/artist",
2796            "https://label.example/artists/artist",
2797        ] {
2798            invalid_steps.push(Mutation::Insert {entity:"event_source".into(),values:literals(json!({"artist_row_id":artist_id,"url":url,"enabled":true,"managed":false,"next_check_at":"2020-01-01T00:00:00Z","last_status":"access_blocked","last_checked_at":"","last_message":""}))});
2799        }
2800        store
2801            .adapter
2802            .transact(
2803                &store.config,
2804                &store.schema,
2805                &Transaction {
2806                    steps: invalid_steps,
2807                    idempotency_key: None,
2808                },
2809            )
2810            .unwrap();
2811        process_store_source(&store, |_| panic!("unrelated source fetched"));
2812        let retired = store
2813            .adapter
2814            .query(
2815                &store.config,
2816                &store.schema,
2817                &Query {
2818                    entity: "event_source".into(),
2819                    fields: vec!["enabled".into()],
2820                    filter: Some(Predicate::Equal(
2821                        "last_status".into(),
2822                        json!("source_removed"),
2823                    )),
2824                    order: vec![],
2825                    limit: 1000,
2826                    offset: 0,
2827                },
2828            )
2829            .unwrap();
2830        assert!(retired.len() >= 3);
2831        assert!(retired.iter().all(|row| row["enabled"] == false));
2832
2833        // Date-only source evidence must still satisfy the timestamp-typed storage contract.
2834        let now = current_timestamp_utc();
2835        let day = pharos_runtime_core::time::timestamp_after_seconds_utc(86400)[..10].to_owned();
2836        let post = json!({"evidence_kind":"rendered_post","audience":"public","description":format!("Next stop: Schema regression festival {day}"),"datePublished":now,"author_url":"https://www.facebook.com/artist","source_profile":"https://www.facebook.com/artist","url":"https://www.facebook.com/artist/posts/123"});
2837        let event = social_announcements::extract(
2838            &post,
2839            "Mad Maxx",
2840            runtime_timestamp_seconds(&now).unwrap(),
2841        )
2842        .unwrap();
2843        let mut legacy = event.clone();
2844        legacy.key =
2845            pharos_runtime_database::operations::operation_fingerprint(&"legacy-announcement")
2846                .unwrap();
2847        legacy.event_url = format!("{}?tracking=legacy", event.event_url);
2848        let values = model::write_values(None, &legacy, artist_id, 1, &now, None).unwrap();
2849        for _ in 0..2 {
2850            store
2851                .adapter
2852                .transact(
2853                    &store.config,
2854                    &store.schema,
2855                    &Transaction {
2856                        steps: vec![Mutation::Upsert {
2857                            entity: "gig_event".into(),
2858                            unique_key: vec!["event_key".into()],
2859                            values: literals(Value::Object(values.clone())),
2860                        }],
2861                        idempotency_key: None,
2862                    },
2863                )
2864                .unwrap();
2865        }
2866        let mut rediscovered = vec![event];
2867        social_announcements::retain_existing_identities(&store, artist_id, &mut rediscovered)
2868            .unwrap();
2869        let event = rediscovered.remove(0);
2870        assert_eq!(event.key, legacy.key);
2871        let rows = store
2872            .adapter
2873            .query(
2874                &store.config,
2875                &store.schema,
2876                &Query {
2877                    entity: "gig_event".into(),
2878                    fields: vec!["starts_at".into(), "venue".into(), "timezone".into()],
2879                    filter: Some(Predicate::Equal("event_key".into(), json!(event.key))),
2880                    order: vec![],
2881                    limit: 2,
2882                    offset: 0,
2883                },
2884            )
2885            .unwrap();
2886        assert_eq!(rows.len(), 1);
2887        assert!(rows[0]["starts_at"].as_str().unwrap().starts_with(&day));
2888        assert_eq!(rows[0]["venue"], "");
2889        assert_eq!(rows[0]["timezone"], "");
2890
2891        store
2892            .adapter
2893            .verify_contract(
2894                &store.config,
2895                &pharos_runtime_database::contract::AppDataContract {
2896                    schema: store.schema.clone(),
2897                    ..contract
2898                },
2899            )
2900            .unwrap();
2901        drop(store);
2902        if db.is_file() {
2903            std::fs::remove_file(db).unwrap();
2904        }
2905    }
2906
2907    /// Keeps model-free extraction limited to complete future performances for the exact artist.
2908    #[test]
2909    /// Keeps source identities stable, rejects contradictory evidence and preserves cancellations.
2910    fn event_identity_conflicts_and_cancellation_are_guarded() {
2911        let now = runtime_timestamp_seconds("2026-10-20T00:00:00Z").unwrap();
2912        let mut input = json!({"@type":"MusicEvent","@id":"https://events.example/id/1","name":"Night","startDate":"2027-01-01T20:00:00+01:00","location":{"name":"Hall","address":{"streetAddress":"Main 1","addressCountry":"France"}},"performer":{"name":"Mad Maxx"},"organizer":{"name":"Local Promoter"},"url":"https://events.example/night"});
2913        let parse = |value: &Value| {
2914            extract_events(
2915                &value.to_string(),
2916                "Mad Maxx",
2917                "https://artist.example/events",
2918                now,
2919            )
2920            .remove(0)
2921        };
2922        let first = parse(&input);
2923        assert_eq!(first.country, "France");
2924        assert_eq!(first.address, "Main 1");
2925        assert_eq!(first.timezone, "+01:00");
2926        assert_eq!(first.organizers, ["Local Promoter"]);
2927        let accepted =
2928            model::write_values(None, &first, 1, 10, "2026-10-20T00:00:00Z", None).unwrap();
2929        input["startDate"] = json!("2027-01-02T20:00:00+01:00");
2930        input["url"] = json!("https://events.example/new-url");
2931        let changed = parse(&input);
2932        assert_eq!(first.key, changed.key);
2933        assert!(model::conflicts(&accepted, &changed, 11));
2934        assert!(!model::conflicts(&accepted, &changed, 10));
2935        input["@id"] = json!("https://events.example/id/2");
2936        assert_ne!(first.key, parse(&input).key);
2937        let mut cancelled = accepted;
2938        cancelled.insert("status".into(), json!("cancelled"));
2939        cancelled.insert("event_state".into(), json!("cancelled"));
2940        assert!(model::conflicts(&cancelled, &changed, 10));
2941        let retained = model::write_values(
2942            Some(&cancelled),
2943            &first,
2944            2,
2945            11,
2946            "2026-10-20T00:00:00Z",
2947            None,
2948        )
2949        .unwrap();
2950        assert_eq!(retained["status"], "cancelled");
2951        assert_eq!(retained["artist_row_id"], 1);
2952        assert_eq!(retained["source_id"], 10);
2953        assert_ne!(
2954            retained["record_updated_at"],
2955            cancelled["record_updated_at"]
2956        );
2957        input["endDate"] = json!("2027-01-01T00:00:00Z");
2958        assert!(
2959            extract_events(
2960                &input.to_string(),
2961                "Mad Maxx",
2962                "https://artist.example/events",
2963                now
2964            )
2965            .is_empty()
2966        );
2967    }
2968
2969    /// The discovery window includes the last thirty days and excludes older performances without weakening identity checks.
2970    #[test]
2971    fn recent_performances_share_verified_event_validation() {
2972        let now = runtime_timestamp_seconds("2026-10-23T12:00:00Z").unwrap();
2973        let mut event = json!({"@type":"MusicEvent","name":"Recent set","startDate":"2026-10-01T20:00:00Z","location":{"name":"Hall"},"performer":{"name":"Shota"},"url":"https://artist.example/event"});
2974        assert!(event_from_value(&event, "Shota", "https://artist.example/", now).is_some());
2975        event["startDate"] = json!("2026-09-23T11:59:59Z");
2976        assert!(event_from_value(&event, "Shota", "https://artist.example/", now).is_none());
2977        event["startDate"] = json!("2026-09-23T12:00:00Z");
2978        assert!(event_from_value(&event, "Shota", "https://artist.example/", now).is_some());
2979        assert!(
2980            event_from_value(&event, "Another artist", "https://artist.example/", now).is_none()
2981        );
2982    }
2983
2984    #[test]
2985    fn structured_events_require_verified_identity_time_and_venue() {
2986        let event = json!({"@type":"MusicEvent","name":"Night","startDate":"2027-01-01T20:00:00+01:00","location":{"name":"Hall"},"performer":{"name":"Mad Maxx"},"url":"https://events.example/night","offers":{"url":"https://tickets.example/night"}});
2987        let now = runtime_timestamp_seconds("2026-10-20T00:00:00Z").unwrap();
2988        let events = extract_events(
2989            &event.to_string(),
2990            "MAD MAXX",
2991            "https://artist.example/events",
2992            now,
2993        );
2994        assert_eq!(events.len(), 1);
2995        assert_eq!(
2996            extract_events(
2997                &format!("<SCRIPT TYPE='application/ld+json'>{event}</SCRIPT>"),
2998                "Mad Maxx",
2999                "https://artist.example/events",
3000                now
3001            )
3002            .len(),
3003            1
3004        );
3005        assert!(events[0].starts_at.starts_with("2027-01-01T19:00:00"));
3006        assert!(
3007            extract_events(
3008                &event.to_string(),
3009                "Max",
3010                "https://artist.example/events",
3011                now
3012            )
3013            .is_empty()
3014        );
3015        let mut incomplete = event.clone();
3016        incomplete["startDate"] = json!("2027-01-01");
3017        assert!(
3018            extract_events(
3019                &incomplete.to_string(),
3020                "Mad Maxx",
3021                "https://artist.example/events",
3022                now
3023            )
3024            .is_empty()
3025        );
3026        assert!(public_link("javascript:alert(1)").is_none());
3027        assert!(public_link("https://user:secret@example.com/").is_none());
3028    }
3029}