diff --git a/.gitignore b/.gitignore index b70cf5b97cd3556e061233d8b0aefd333568a991..543e059a212887be1f4263494aa6b7fdd958a7fa 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ test/playwright/__pycache__/ scripts/*.js scripts/*.mjs scripts/*.cjs +tmp/ diff --git a/agents.md b/agents.md index b9644da485450f7767e65f079281aadfb9b2e76a..18dc65564f2c48937ffffa7632c9141d73b21eef 100644 --- a/agents.md +++ b/agents.md @@ -48,7 +48,8 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma - **`ThreadGraduate` / `GraduateThread`:** Private-room forum threads with **Manage** can be published to the public site under the same tag. The writer replays non-redacted ingests into **`room: public`** (chronological order), then appends a durable **`ThreadGraduated`** marker. Graduated private threads show a banner linking to public **`/t/:tag`**, block further private posts, and cannot be graduated twice. CLI: **`npx slugsocial private forum graduate `**; RPC: **`ThreadGraduate`**. - **`ResolveExternal`:** GitHub resolver buttons are browser actions through **`POST /ui`**. Success responses morph **`#external-resolver-status`** then redirect to the sanitized shareable **`GET`** page so imported children render through the normal page path; errors morph the same status region. Resolver results are durable system ingests, while cooldown state is RAM-only. Implementation lives under **`server/src/resolvers/`** (GitHub resolver + import card JSON); ontology item pages and the **`GET /vote/compare`** left/right columns use **`render_item_body_in_scope`** in **`server/src/html/mod.rs`**, which calls **`server/src/resolvers/mod.rs::try_render_resolver_item_body`** before falling back to the usual **`
`** linkified view.
-  - **Issues:** each open issue is its own **`system:github-resolver`** ingest in the import thread (so a later redact removes that issue from the garden). Refresh pages **all** open issues from the GitHub API (up to the page safety cap), keeps still-open single-issue posts, and **`SystemRedact`s** posts for closed/missing issues (and legacy multi-issue bulk posts, which are re-imported as singles).
+  - **Import thread:** one forum thread per GitHub repo (`import:https:::github.com:{owner}:{repo}`), shared by repo root / issues / pulls / commits / releases imports.
+  - **Issues:** each open issue is its own **`system:github-resolver`** ingest in that repo import thread (so a later redact removes that issue from the garden). Refresh pages **all** open issues from the GitHub API (up to the page safety cap), keeps still-open single-issue posts, and **`SystemRedact`s** posts for closed/missing issues (and multi-issue bulk posts, which are re-imported as singles). The `slug-github-card` fence payload is **base64(JSON)** so markdown fences in issue bodies cannot terminate the DSL toggle-fence; decode yields the real excerpt string for rich/markdown rendering.
 
 - **Garden pin / compare voting:** Cookie **`slug_garden_pin`** via **`set_garden_pin`**. Pairwise UI: **`GET /vote/compare?…`** / **`GET /r/:room_key/vote/compare?…`** (fullscreen **`GET`** page: no HUD; other garden pages). HUD (**`#slug-pin-hud`**): only when **`layout`** passes garden metadata on **`body`**; the label is **`POST /ui`** **`set_garden_pin`** **`clear:true`** (**`slug_ui.js`**), not a permalink to the item.
 
diff --git a/scripts/fly-events-common.sh b/scripts/fly-events-common.sh
new file mode 100755
index 0000000000000000000000000000000000000000..56007973a017f0f8d66d55e933f11be30e2cbe29
--- /dev/null
+++ b/scripts/fly-events-common.sh
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+# Shared helpers for fly-events-{pull,push}.sh
+# Sourced only — do not execute directly.
+
+FLY_APP="${FLY_APP:-slugsocial}"
+REMOTE_EVENTS="${REMOTE_EVENTS:-/data/events.jsonl}"
+
+fly_bin() {
+  if command -v flyctl >/dev/null 2>&1; then
+    echo flyctl
+  elif command -v fly >/dev/null 2>&1; then
+    echo fly
+  else
+    echo "error: neither flyctl nor fly found on PATH" >&2
+    return 1
+  fi
+}
+
+# Resolve a started machine id. Honors FLY_MACHINE if set.
+resolve_machine() {
+  local fly app mid
+  fly="$(fly_bin)" || return 1
+  app="${1:-$FLY_APP}"
+
+  if [[ -n "${FLY_MACHINE:-}" ]]; then
+    echo "$FLY_MACHINE"
+    return 0
+  fi
+
+  mid="$("$fly" machines list -a "$app" --json 2>/dev/null \
+    | python3 -c '
+import json, sys
+machines = json.load(sys.stdin)
+started = [m for m in machines if (m.get("state") or "").lower() == "started"]
+pick = started or machines
+if not pick:
+    sys.exit("no machines found")
+print(pick[0]["id"])
+')" || return 1
+
+  if [[ -z "$mid" ]]; then
+    echo "error: could not resolve machine id for app=$app" >&2
+    return 1
+  fi
+  echo "$mid"
+}
diff --git a/scripts/fly-events-filter-github-imports.py b/scripts/fly-events-filter-github-imports.py
new file mode 100755
index 0000000000000000000000000000000000000000..ba6a73f3cd41134f90bd87e310983908af17df33
--- /dev/null
+++ b/scripts/fly-events-filter-github-imports.py
@@ -0,0 +1,188 @@
+#!/usr/bin/env python3
+"""Filter GitHub-resolver import spam from a slug events.jsonl.
+
+Removes:
+  - Ingest events where principal == system:github-resolver
+  - Ingest events whose thread_tag starts with import:https:::github.com:
+    (optionally narrowed with --only-substring, e.g. litellm)
+  - PostRedacted events whose post_id refers to a removed Ingest id
+
+Does not touch unrelated human / AI posts.
+
+Usage:
+  scripts/fly-events-filter-github-imports.py INPUT [-o OUTPUT] [--dry-run]
+  scripts/fly-events-filter-github-imports.py INPUT -o OUT --only-substring litellm
+  scripts/fly-events-filter-github-imports.py INPUT --report-only
+
+Defaults write OUTPUT next to INPUT as .cleaned.jsonl when -o omitted
+(unless --dry-run / --report-only).
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from collections import Counter
+from pathlib import Path
+from typing import Any
+
+
+GITHUB_RESOLVER = "system:github-resolver"
+IMPORT_PREFIX = "import:https:::github.com:"
+
+
+def event_payload(obj: dict[str, Any]) -> tuple[str, dict[str, Any]]:
+    etype = obj.get("type")
+    if not isinstance(etype, str):
+        raise ValueError("missing type")
+    return etype, obj
+
+
+def ingest_matches(ev: dict[str, Any], only_substring: str | None) -> bool:
+    principal = ev.get("principal") or ""
+    thread_tag = ev.get("thread_tag") or ""
+    is_resolver = principal == GITHUB_RESOLVER
+    is_gh_import = isinstance(thread_tag, str) and thread_tag.startswith(IMPORT_PREFIX)
+    if not (is_resolver or is_gh_import):
+        return False
+    if only_substring:
+        hay = f"{principal}\n{thread_tag}\n{ev.get('raw') or ''}"
+        return only_substring.lower() in hay.lower()
+    return True
+
+
+def classify_line(
+    line: str,
+    only_substring: str | None,
+) -> tuple[str, str | None, dict[str, Any] | None]:
+    """Return (kind, ingest_id_or_none, parsed). kind in keep|drop_ingest|drop_redact|bad."""
+    raw = line.strip()
+    if not raw:
+        return "keep", None, None
+    try:
+        obj = json.loads(raw)
+    except json.JSONDecodeError:
+        return "bad", None, None
+    if not isinstance(obj, dict):
+        return "bad", None, None
+    etype, ev = event_payload(obj)
+    if etype == "ingest":
+        if ingest_matches(ev, only_substring):
+            return "drop_ingest", ev.get("id"), ev
+        return "keep", None, ev
+    if etype == "post_redacted":
+        # Second pass decides; mark tentatively
+        return "maybe_redact", ev.get("post_id"), ev
+    return "keep", None, ev
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+    ap.add_argument("input", type=Path, help="Source events.jsonl")
+    ap.add_argument("-o", "--output", type=Path, help="Destination cleaned jsonl")
+    ap.add_argument(
+        "--only-substring",
+        default=None,
+        help="Only drop events whose principal/thread_tag/raw contain this (case-insensitive)",
+    )
+    ap.add_argument(
+        "--dry-run",
+        action="store_true",
+        help="Report what would be removed; do not write output",
+    )
+    ap.add_argument(
+        "--report-only",
+        action="store_true",
+        help="Alias for --dry-run",
+    )
+    args = ap.parse_args()
+    dry = args.dry_run or args.report_only
+
+    if not args.input.is_file():
+        print(f"error: input not found: {args.input}", file=sys.stderr)
+        return 1
+
+    out = args.output
+    if out is None and not dry:
+        out = args.input.with_name(args.input.stem + ".cleaned.jsonl")
+
+    lines = args.input.read_text(encoding="utf-8").splitlines(keepends=True)
+
+    # Pass 1: collect ingest ids to drop + sample tags
+    drop_ids: set[str] = set()
+    drop_ingest_lines: set[int] = set()
+    tag_counts: Counter[str] = Counter()
+    samples: list[str] = []
+    bad = 0
+
+    for i, line in enumerate(lines):
+        kind, iid, ev = classify_line(line, args.only_substring)
+        if kind == "bad":
+            bad += 1
+            continue
+        if kind == "drop_ingest":
+            drop_ingest_lines.add(i)
+            if isinstance(iid, str) and iid:
+                drop_ids.add(iid)
+            if ev:
+                tag = str(ev.get("thread_tag") or "")
+                tag_counts[tag] += 1
+                if len(samples) < 12:
+                    samples.append(
+                        f"  ingest id={iid} ts={ev.get('ts')} tag={tag!r} principal={ev.get('principal')!r}"
+                    )
+
+    # Pass 2: drop matching redactions + emit
+    kept: list[str] = []
+    drop_redact = 0
+    drop_ingest = 0
+    kept_n = 0
+
+    for i, line in enumerate(lines):
+        if i in drop_ingest_lines:
+            drop_ingest += 1
+            continue
+        kind, pid, ev = classify_line(line, args.only_substring)
+        if kind == "maybe_redact" and isinstance(pid, str) and pid in drop_ids:
+            drop_redact += 1
+            continue
+        # Also drop PostRedacted by github-resolver that target dropped ids already handled;
+        # additionally drop redactions authored as github-resolver for safety when post is gone.
+        if kind == "maybe_redact" and ev and (ev.get("principal") == GITHUB_RESOLVER):
+            # Only drop if the referenced post was a dropped ingest; otherwise keep
+            # (shouldn't happen for human posts).
+            if isinstance(pid, str) and pid in drop_ids:
+                drop_redact += 1
+                continue
+        kept.append(line if line.endswith("\n") else line + "\n")
+        kept_n += 1
+
+    removed = drop_ingest + drop_redact
+    print(f"input:  {args.input} ({len(lines)} lines)")
+    if args.only_substring:
+        print(f"filter: substring={args.only_substring!r}")
+    print(f"remove: {drop_ingest} ingest(s), {drop_redact} post_redacted, total={removed}")
+    print(f"keep:   {kept_n} lines (bad/unparsed kept as-is: {bad})")
+    if tag_counts:
+        print("removed thread_tag counts:")
+        for tag, n in tag_counts.most_common(40):
+            print(f"  {n:5d}  {tag}")
+    if samples:
+        print("sample removed ingests:")
+        for s in samples:
+            print(s)
+
+    if dry:
+        print("dry-run: no output written")
+        return 0
+
+    assert out is not None
+    out.parent.mkdir(parents=True, exist_ok=True)
+    out.write_text("".join(kept), encoding="utf-8")
+    print(f"output: {out} ({kept_n} lines)")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/scripts/fly-events-pull.sh b/scripts/fly-events-pull.sh
new file mode 100755
index 0000000000000000000000000000000000000000..e854ff04e7d408d5e84a57817bed41e611829fbf
--- /dev/null
+++ b/scripts/fly-events-pull.sh
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+# Download production events.jsonl from the Fly volume to a local path.
+#
+# Usage:
+#   scripts/fly-events-pull.sh [local-path]
+#
+# Env:
+#   FLY_APP          app name (default: slugsocial)
+#   FLY_MACHINE      pin a machine id (optional; otherwise first started machine)
+#   REMOTE_EVENTS    remote path (default: /data/events.jsonl)
+#
+# Example:
+#   scripts/fly-events-pull.sh
+#   scripts/fly-events-pull.sh ./tmp/events.jsonl
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# shellcheck source=fly-events-common.sh
+source "$SCRIPT_DIR/fly-events-common.sh"
+
+LOCAL_PATH="${1:-./tmp/events.jsonl}"
+FLY="$(fly_bin)"
+MACHINE="$(resolve_machine "$FLY_APP")"
+
+mkdir -p "$(dirname "$LOCAL_PATH")"
+
+echo "pull: app=$FLY_APP machine=$MACHINE"
+echo "pull: $REMOTE_EVENTS -> $LOCAL_PATH"
+
+# fly sftp get refuses to overwrite an existing local file — use a fresh path.
+tmp="${LOCAL_PATH}.partial.$$"
+rm -f "$tmp"
+cleanup() { rm -f "$tmp"; }
+trap cleanup EXIT
+
+"$FLY" sftp get "$REMOTE_EVENTS" "$tmp" -a "$FLY_APP" --machine "$MACHINE"
+mv -f "$tmp" "$LOCAL_PATH"
+trap - EXIT
+
+bytes="$(wc -c <"$LOCAL_PATH" | tr -d ' ')"
+lines="$(wc -l <"$LOCAL_PATH" | tr -d ' ')"
+echo "pull: ok ($lines lines, $bytes bytes) -> $LOCAL_PATH"
diff --git a/scripts/fly-events-push.sh b/scripts/fly-events-push.sh
new file mode 100755
index 0000000000000000000000000000000000000000..0040f89fc71648aa31d7030e3f755a44aff72644
--- /dev/null
+++ b/scripts/fly-events-push.sh
@@ -0,0 +1,133 @@
+#!/usr/bin/env bash
+# Upload a local events.jsonl to the Fly volume and restart so reducer state reloads.
+#
+# Usage:
+#   scripts/fly-events-push.sh  [--no-restart] [--yes]
+#
+# Steps:
+#   1. Resolve machine
+#   2. Backup remote /data/events.jsonl -> /data/events.jsonl.bak.
+#   3. Upload local file to /data/events.jsonl.upload then atomic mv into place
+#   4. Restart the machine (unless --no-restart)
+#   5. Probe /healthz
+#
+# Env:
+#   FLY_APP, FLY_MACHINE, REMOTE_EVENTS  (same as pull)
+#   SKIP_CONFIRM=1                       same as --yes
+#
+# Example:
+#   scripts/fly-events-push.sh ./tmp/events.cleaned.jsonl --yes
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# shellcheck source=fly-events-common.sh
+source "$SCRIPT_DIR/fly-events-common.sh"
+
+LOCAL_PATH=""
+NO_RESTART=0
+YES=0
+
+usage() {
+  sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
+  exit 2
+}
+
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --no-restart) NO_RESTART=1; shift ;;
+    --yes|-y) YES=1; shift ;;
+    -h|--help) usage ;;
+    -*)
+      echo "error: unknown flag: $1" >&2
+      usage
+      ;;
+    *)
+      if [[ -n "$LOCAL_PATH" ]]; then
+        echo "error: unexpected argument: $1" >&2
+        usage
+      fi
+      LOCAL_PATH="$1"
+      shift
+      ;;
+  esac
+done
+
+if [[ -z "$LOCAL_PATH" ]]; then
+  echo "error: local path required" >&2
+  usage
+fi
+if [[ ! -f "$LOCAL_PATH" ]]; then
+  echo "error: not a file: $LOCAL_PATH" >&2
+  exit 1
+fi
+if [[ "${SKIP_CONFIRM:-0}" == "1" ]]; then
+  YES=1
+fi
+
+FLY="$(fly_bin)"
+MACHINE="$(resolve_machine "$FLY_APP")"
+LOCAL_BYTES="$(wc -c <"$LOCAL_PATH" | tr -d ' ')"
+LOCAL_LINES="$(wc -l <"$LOCAL_PATH" | tr -d ' ')"
+STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
+REMOTE_BAK="${REMOTE_EVENTS}.bak.${STAMP}"
+REMOTE_UPLOAD="${REMOTE_EVENTS}.upload.${STAMP}"
+
+echo "push: app=$FLY_APP machine=$MACHINE"
+echo "push: local=$LOCAL_PATH ($LOCAL_LINES lines, $LOCAL_BYTES bytes)"
+echo "push: remote=$REMOTE_EVENTS"
+echo "push: backup=$REMOTE_BAK"
+if [[ "$NO_RESTART" -eq 1 ]]; then
+  echo "push: restart=skipped"
+else
+  echo "push: restart=yes (after upload)"
+fi
+
+if [[ "$YES" -ne 1 ]]; then
+  read -r -p "Overwrite production event log and reload? [y/N] " ans
+  case "$ans" in
+    y|Y|yes|YES) ;;
+    *) echo "aborted"; exit 1 ;;
+  esac
+fi
+
+# fly ssh -C splits argv awkwardly; always run through sh -c.
+remote_sh() {
+  local cmd="$1"
+  "$FLY" ssh console -a "$FLY_APP" --machine "$MACHINE" -C "sh -c $(printf '%q' "$cmd")"
+}
+
+echo "push: backing up remote log..."
+remote_sh "cp -f '$REMOTE_EVENTS' '$REMOTE_BAK' && ls -la '$REMOTE_EVENTS' '$REMOTE_BAK'"
+# Verify backup is non-empty and matches source size (guards against silent truncate).
+remote_sh "src=\$(wc -c <'$REMOTE_EVENTS'); bak=\$(wc -c <'$REMOTE_BAK'); echo \"sizes src=\$src bak=\$bak\"; test \"\$src\" -gt 0 && test \"\$src\" -eq \"\$bak\""
+
+echo "push: uploading to $REMOTE_UPLOAD ..."
+rm -f "${LOCAL_PATH}.flyupload" 2>/dev/null || true
+"$FLY" sftp put "$LOCAL_PATH" "$REMOTE_UPLOAD" -a "$FLY_APP" --machine "$MACHINE"
+
+echo "push: atomically replacing $REMOTE_EVENTS ..."
+remote_sh "mv -f '$REMOTE_UPLOAD' '$REMOTE_EVENTS' && ls -la '$REMOTE_EVENTS' '$REMOTE_BAK' && wc -l '$REMOTE_EVENTS' && test \$(wc -c <'$REMOTE_EVENTS') -eq $LOCAL_BYTES"
+
+if [[ "$NO_RESTART" -eq 0 ]]; then
+  echo "push: restarting machine $MACHINE ..."
+  "$FLY" machine restart "$MACHINE" -a "$FLY_APP"
+  echo "push: waiting for health..."
+  ok=0
+  for i in $(seq 1 36); do
+    if curl -fsS --max-time 5 "https://${FLY_APP}.fly.dev/healthz" >/dev/null 2>&1; then
+      ok=1
+      break
+    fi
+    sleep 5
+  done
+  if [[ "$ok" -eq 1 ]]; then
+    echo "push: healthz ok"
+  else
+    echo "push: warning — healthz did not pass within ~3m; check: fly status -a $FLY_APP" >&2
+    exit 1
+  fi
+fi
+
+echo "push: done"
+echo "push: remote backup retained at $REMOTE_BAK"
diff --git a/server/src/html/garden/tests.rs b/server/src/html/garden/tests.rs
index 1e2ec7148caf4411984f77e8d7b07e037d6b1b3e..b3a7264771f766d65886ceaa5184181a03f4f930 100644
--- a/server/src/html/garden/tests.rs
+++ b/server/src/html/garden/tests.rs
@@ -399,7 +399,9 @@ fn vote_compare_item_card_renders_github_import_markup() {
         "headline": "#1 Compare card",
         "sublines": ["State: open"],
     });
-    let body = format!("```slug-github-card\n{json}\n```");
+    use base64::{engine::general_purpose::STANDARD, Engine as _};
+    let payload = STANDARD.encode(json.to_string().as_bytes());
+    let body = format!("```slug-github-card\n{payload}\n```");
     let html = vote_compare_item_card(
         &nav,
         &item,
diff --git a/server/src/resolvers/github.rs b/server/src/resolvers/github.rs
index 551010e857447b7d5125acd9f1461be5d85a6140..2a7060eecf7daf4bbe792c9035e32b9d9fd52d8b 100644
--- a/server/src/resolvers/github.rs
+++ b/server/src/resolvers/github.rs
@@ -384,17 +384,46 @@ fn github_repo_sections(owner: &str, repo: &str) -> Vec {
     .collect()
 }
 
+/// Stable import thread for a GitHub URL: one thread per repo (`owner/repo`),
+/// or per owner when resolving a user/org page. Section suffixes (`/issues`, etc.)
+/// must not create additional threads.
 fn resolver_thread_tag(item: &ItemId) -> String {
-    let tail = item
-        .display_path()
-        .trim_start_matches("-/")
-        .replace(['/', '?'], ":");
+    let path = match github_segments(item).as_deref() {
+        Some([owner, repo, ..]) => format!("https://github.com/{owner}/{repo}"),
+        Some([owner]) => format!("https://github.com/{owner}"),
+        _ => item
+            .display_path()
+            .trim_start_matches("-/")
+            .to_string(),
+    };
+    let tail = path.replace(['/', '?'], ":");
     format!("import:{tail}")
 }
 
+/// Encode a card for a ```slug-github-card``` fence.
+///
+/// DSL code fences are **toggle** markers (see `BlockMasker`): a ``` inside the
+/// fence payload closes it. The UUID/token substitution protects fence *contents*
+/// from brace matching and allows *sibling* fences inside `{ … }`, but it cannot
+/// nest fences. Issue bodies often contain markdown fences, so the JSON card is
+/// stored as base64 — opaque to the masker, decoded back to real markdown for
+/// rendering (and a future markdown renderer on `excerpt`).
+fn card_payload_for_dsl_fence(card: &GithubImportCard) -> String {
+    use base64::{engine::general_purpose::STANDARD, Engine as _};
+    let json = serde_json::to_string(card).unwrap_or_else(|_| "{}".to_string());
+    STANDARD.encode(json.as_bytes())
+}
+
+fn decode_github_card_payload(payload: &str) -> Option {
+    use base64::{engine::general_purpose::STANDARD, Engine as _};
+    let bytes = STANDARD.decode(payload.trim()).ok()?;
+    let s = std::str::from_utf8(&bytes).ok()?;
+    serde_json::from_str(s).ok()
+}
+
 fn child_to_dsl(child: &ResolvedChild) -> String {
-    let json = serde_json::to_string(&child.card).unwrap_or_else(|_| "{}".to_string());
-    let inner = format!("```slug-github-card\n{json}\n```");
+    let payload = card_payload_for_dsl_fence(&child.card);
+    let inner = format!("```slug-github-card\n{payload}\n```");
     format!("{} {{\n{}\n}}\n", child.url, inner)
 }
 
@@ -490,7 +519,7 @@ async fn system_redact(state: &AppState, post_id: &str) -> Result<(), String> {
     Ok(())
 }
 
-/// One post per open issue; redact posts for closed/missing issues (and legacy bulk posts).
+/// One post per open issue; redact posts for closed/missing issues (and bulk posts).
 async fn resolve_github_issues(
     state: &AppState,
     room: &str,
@@ -770,29 +799,9 @@ fn extract_fence<'a>(body: &'a str, lang: &str) -> Option<&'a str> {
 }
 
 fn parse_github_import_from_body(body: &str) -> Option {
-    let trimmed = body.trim();
-    if let Some(json) = extract_fence(trimmed, "slug-github-card") {
-        let c: GithubImportCard = serde_json::from_str(json).ok()?;
-        return (c.v == 1 && (c.schema.is_empty() || c.schema == SLUG_GITHUB_SCHEMA)).then_some(c);
-    }
-    if let Some(json) = extract_fence(trimmed, "json") {
-        if let Ok(c) = serde_json::from_str::(json) {
-            if c.v == 1
-                && (c.schema == SLUG_GITHUB_SCHEMA
-                    || (c.schema.is_empty() && c.url.contains("github.com")))
-            {
-                return Some(c);
-            }
-        }
-    }
-    if trimmed.starts_with('{') {
-        let c: GithubImportCard = serde_json::from_str(trimmed).ok()?;
-        return (c.v == 1
-            && (c.schema == SLUG_GITHUB_SCHEMA
-                || (c.schema.is_empty() && c.url.contains("github.com"))))
-        .then_some(c);
-    }
-    None
+    let payload = extract_fence(body.trim(), "slug-github-card")?;
+    let c = decode_github_card_payload(payload)?;
+    (c.v == 1 && (c.schema.is_empty() || c.schema == SLUG_GITHUB_SCHEMA)).then_some(c)
 }
 
 fn kind_badge(kind: &GithubImportKind) -> &'static str {
@@ -876,6 +885,19 @@ mod tests {
         );
     }
 
+    #[test]
+    fn resolver_thread_tag_is_one_per_repo() {
+        let repo = ItemId::parse("https://github.com/berriai/litellm").unwrap();
+        let issues = ItemId::parse("https://github.com/berriai/litellm/issues").unwrap();
+        let issue = ItemId::parse("https://github.com/berriai/litellm/issues/1").unwrap();
+        let pulls = ItemId::parse("https://github.com/berriai/litellm/pulls").unwrap();
+        let expected = "import:https:::github.com:berriai:litellm";
+        assert_eq!(resolver_thread_tag(&repo), expected);
+        assert_eq!(resolver_thread_tag(&issues), expected);
+        assert_eq!(resolver_thread_tag(&issue), expected);
+        assert_eq!(resolver_thread_tag(&pulls), expected);
+    }
+
     #[test]
     fn repo_sections_are_direct_children() {
         let sections = github_repo_sections("sortersocial", "slug");
@@ -897,7 +919,15 @@ mod tests {
         }]);
         assert!(dsl.contains("https://github.com/o/r/issues/1"));
         assert!(dsl.contains("```slug-github-card"));
-        assert!(dsl.contains("\"schema\":\"slug_github_import\""));
+        // Payload is base64 so nested ``` in excerpts cannot break DSL fences.
+        assert!(!dsl.contains("\"schema\":\"slug_github_import\""));
+        let body = dsl
+            .split_once('{')
+            .and_then(|(_, rest)| rest.rsplit_once('}'))
+            .map(|(inner, _)| inner.trim())
+            .expect("braced body");
+        let parsed = parse_github_import_from_body(body).expect("decodes base64 card");
+        assert_eq!(parsed.schema, SLUG_GITHUB_SCHEMA);
     }
 
     #[test]
@@ -919,14 +949,17 @@ mod tests {
     #[test]
     fn issue_urls_declared_reads_single_and_bulk_posts() {
         let parent = ItemId::parse("https://github.com/o/r/issues").unwrap();
-        let single = concat!(
-            "https://github.com/o/r/issues/1 {\n",
-            "```slug-github-card\n",
-            "{\"v\":1,\"schema\":\"slug_github_import\",\"kind\":\"issue\",\"url\":\"https://github.com/o/r/issues/1\",\"headline\":\"#1\"}",
-            "\n```\n}\n"
-        );
+        let single = child_to_dsl(&ResolvedChild {
+            url: "https://github.com/o/r/issues/1".into(),
+            title: "#1".into(),
+            card: GithubImportCard::new(
+                GithubImportKind::Issue,
+                "https://github.com/o/r/issues/1".into(),
+                "#1".into(),
+            ),
+        });
         assert_eq!(
-            issue_urls_declared_in_ingest(single, &parent),
+            issue_urls_declared_in_ingest(&single, &parent),
             vec!["https://github.com/o/r/issues/1".to_string()]
         );
 
@@ -947,22 +980,29 @@ mod tests {
             "https://github.com/o/r".into(),
             "o/r".into(),
         );
-        let body = format!("```slug-github-card\n{}\n```\n", serde_json::to_string(&card).unwrap());
+        let body = format!(
+            "```slug-github-card\n{}\n```\n",
+            card_payload_for_dsl_fence(&card)
+        );
         let parsed = parse_github_import_from_body(&body).expect("parses");
         assert_eq!(parsed, card);
     }
 
     #[test]
-    fn parse_accepts_schema_json_fence() {
+    fn parse_rejects_raw_json_slug_github_fence() {
         let card = GithubImportCard::new(
             GithubImportKind::Issue,
             "https://github.com/o/r/issues/2".into(),
             "#2 hi".into(),
         );
-        let json = serde_json::to_string(&card).unwrap();
-        let body = format!("```json\n{json}\n```");
-        let parsed = parse_github_import_from_body(&body).expect("parses json fence");
-        assert_eq!(parsed.headline, "#2 hi");
+        let body = format!(
+            "```slug-github-card\n{}\n```",
+            serde_json::to_string(&card).unwrap()
+        );
+        assert!(
+            parse_github_import_from_body(&body).is_none(),
+            "raw JSON inside slug-github-card is not accepted"
+        );
     }
 
     #[test]
@@ -1009,6 +1049,136 @@ mod tests {
             .expect("single issue card should validate");
     }
 
+    fn assert_child_dsl_ingests_cleanly(issue_body: &str) {
+        let child = ResolvedChild {
+            url: "https://github.com/berriai/litellm/issues/1".into(),
+            title: "#1 repro".into(),
+            card: card_for_issue(
+                &serde_json::json!({
+                    "number": 1,
+                    "title": "repro",
+                    "state": "open",
+                    "user": {"login": "octo"},
+                    "labels": [],
+                    "body": issue_body
+                }),
+                "https://github.com/berriai/litellm/issues/1",
+                GithubImportKind::Issue,
+            ),
+        };
+        let text = child_to_dsl(&child);
+        let reduced = crate::reducer::ReducerState::default();
+        let validated = crate::api::validate_ingest_document(
+            &reduced,
+            &text,
+            &crate::reducer::ScopeId::Public,
+        )
+        .unwrap_or_else(|(code, msg, hint)| {
+            panic!(
+                "github import DSL should validate; got {code} {msg} hint={hint:?}\nDSL:\n{text}"
+            )
+        });
+        let item_body = validated
+            .doc
+            .statements
+            .iter()
+            .find_map(|s| match s {
+                crate::dsl::Stmt::Item { body: Some(b), .. } => Some(b.as_str()),
+                _ => None,
+            })
+            .unwrap_or_else(|| panic!("expected item body\nDSL:\n{text}"));
+        let parsed = parse_github_import_from_body(item_body).unwrap_or_else(|| {
+            panic!("body should round-trip as github card; body was:\n{item_body}\nDSL:\n{text}")
+        });
+        assert!(
+            child.card.excerpt.as_ref().is_some_and(|e| e.contains("```")),
+            "precondition: card excerpt contains markdown fences"
+        );
+        assert_eq!(
+            parsed.excerpt.as_deref(),
+            child.card.excerpt.as_deref(),
+            "excerpt should survive DSL fence masking/unmasking"
+        );
+    }
+
+    #[test]
+    fn child_to_dsl_validates_when_issue_body_contains_balanced_markdown_fences() {
+        // Existing happy-path tests only used plain excerpts, so fence-bearing
+        // GitHub markdown was never exercised end-to-end through ingest validation.
+        assert_child_dsl_ingests_cleanly(concat!(
+            "Prefer A > B when ranking.\n\n",
+            "```python\n",
+            "print('hi')\n",
+            "```\n\n",
+            "Closing thoughts."
+        ));
+    }
+
+    #[test]
+    fn child_to_dsl_validates_when_issue_body_has_truncated_markdown_fence() {
+        // Common in the wild: issue opens a ```json/py fence and either never
+        // closes it, or our 1200-char excerpt cuts off before the closer.
+        // Nested ``` inside a toggle fence would break masking if the card JSON
+        // were stored raw; base64 payload keeps the outer fence intact.
+        assert_child_dsl_ingests_cleanly(concat!(
+            "## Describe the bug\n\n",
+            "Using a skill with:\n\n",
+            "```json\n",
+            "\"container\": {\n",
+            "  \"skills\": [{\"type\": \"custom\", \"skill_id\": \"x\"}]\n",
+        ));
+    }
+
+    #[test]
+    fn children_to_dsl_validates_when_earlier_issue_body_has_markdown_fence() {
+        // Bulk ingest path (also a good stress test for fence leakage across
+        // concatenated items). A ``` inside issue 1's card must not make issue 2
+        // parse as a bare comparison / vote.
+        let kids = [
+            ResolvedChild {
+                url: "https://github.com/berriai/litellm/issues/1".into(),
+                title: "#1".into(),
+                card: card_for_issue(
+                    &serde_json::json!({
+                        "number": 1,
+                        "title": "one",
+                        "state": "open",
+                        "user": {"login": "octo"},
+                        "labels": [],
+                        "body": "## bug\n\n```json\n{\"a\":1}\n"
+                    }),
+                    "https://github.com/berriai/litellm/issues/1",
+                    GithubImportKind::Issue,
+                ),
+            },
+            ResolvedChild {
+                url: "https://github.com/berriai/litellm/issues/2".into(),
+                title: "#2".into(),
+                card: card_for_issue(
+                    &serde_json::json!({
+                        "number": 2,
+                        "title": "two",
+                        "state": "open",
+                        "user": {"login": "octo"},
+                        "labels": [],
+                        "body": "plain body"
+                    }),
+                    "https://github.com/berriai/litellm/issues/2",
+                    GithubImportKind::Issue,
+                ),
+            },
+        ];
+        let text = children_to_dsl(&kids);
+        let reduced = crate::reducer::ReducerState::default();
+        crate::api::validate_ingest_document(&reduced, &text, &crate::reducer::ScopeId::Public)
+            .unwrap_or_else(|(code, msg, hint)| {
+                panic!(
+                    "bulk github import DSL should validate; got {code} {msg} hint={hint:?}\nDSL:\n{text}"
+                )
+            });
+    }
+
+
     #[tokio::test]
     async fn list_issues_pages_until_exhausted() {
         use axum::{routing::get, Json, Router};
diff --git a/server/src/resolvers/mod.rs b/server/src/resolvers/mod.rs
index eed2ef353ff71de332d4007972eb28a321591f5e..702939fa1288e7da6fab0306605cc0a4b2fa9bc4 100644
--- a/server/src/resolvers/mod.rs
+++ b/server/src/resolvers/mod.rs
@@ -1,6 +1,7 @@
 //! Domain resolvers (GitHub, …) and matching HTML renderers for imported item bodies.
 //!
-//! Resolver output is ingested as DSL; bodies may embed a `slug-github-card` fenced JSON
+//! Resolver output is ingested as DSL; bodies may embed a `slug-github-card` fence
+//! (base64-encoded card JSON)
 //! envelope that [`crate::html::render_item_body_in_scope`] renders instead of a raw `
`.
 
 pub mod github;
diff --git a/server/static/theme_retro.css b/server/static/theme_retro.css
index 00b304fe40f6bbae28b3d1649742979fa501f0c8..7280a0df8e90640f18a6d6746b6c45455c9387ae 100644
--- a/server/static/theme_retro.css
+++ b/server/static/theme_retro.css
@@ -327,6 +327,42 @@ body.view-ontology .vote-compare-right .vote-compare-item-body .item-body-rich {
   flex-direction: column;
   align-items: flex-end;
 }
+/* GitHub import cards (garden item bodies + vote compare) */
+article.github-import-card {
+  border: 1px solid #888;
+  padding: 0.65rem 0.75rem;
+  margin: 0.5rem 0;
+  max-width: 100%;
+}
+.github-import-card__badge {
+  display: block;
+  font-size: 0.78em;
+  color: #666;
+  margin-bottom: 0.25rem;
+}
+.github-import-card__title {
+  margin: 0;
+  font-size: 1.05em;
+}
+ul.github-import-card__meta {
+  margin: 0.45rem 0 0 1.1em;
+  padding: 0;
+  font-size: 0.9em;
+}
+.github-import-card__excerpt {
+  margin-top: 0.6rem;
+  font-family: var(--font-prose);
+  font-size: 0.95em;
+  white-space: pre-wrap;
+}
+.github-import-card__excerpt p {
+  margin: 0.4rem 0;
+}
+.github-import-card__link {
+  margin-top: 0.65rem;
+  font-size: 0.95em;
+}
+
 body.view-ontology .vote-compare-item-body .item-body-rich article.github-import-card {
   box-sizing: border-box;
   width: 100%;
diff --git a/server/static/theme_retro_craft.css b/server/static/theme_retro_craft.css
index 145c1c779a9b5743f9d4ed13b360376cbe3d932d..651d864e4a94d3e16ccbf4ba115d4b40af51c282 100644
--- a/server/static/theme_retro_craft.css
+++ b/server/static/theme_retro_craft.css
@@ -948,6 +948,42 @@ body.view-ontology .vote-compare-right .vote-compare-item-body .item-body-rich {
   flex-direction: column;
   align-items: flex-end;
 }
+/* GitHub import cards (garden item bodies + vote compare) */
+article.github-import-card {
+  border: 1px solid #888;
+  padding: 0.65rem 0.75rem;
+  margin: 0.5rem 0;
+  max-width: 100%;
+}
+.github-import-card__badge {
+  display: block;
+  font-size: 0.78em;
+  color: #666;
+  margin-bottom: 0.25rem;
+}
+.github-import-card__title {
+  margin: 0;
+  font-size: 1.05em;
+}
+ul.github-import-card__meta {
+  margin: 0.45rem 0 0 1.1em;
+  padding: 0;
+  font-size: 0.9em;
+}
+.github-import-card__excerpt {
+  margin-top: 0.6rem;
+  font-family: var(--font-prose);
+  font-size: 0.95em;
+  white-space: pre-wrap;
+}
+.github-import-card__excerpt p {
+  margin: 0.4rem 0;
+}
+.github-import-card__link {
+  margin-top: 0.65rem;
+  font-size: 0.95em;
+}
+
 body.view-ontology .vote-compare-item-body .item-body-rich article.github-import-card {
   box-sizing: border-box;
   width: 100%;
diff --git a/server/tests/integration_html.rs b/server/tests/integration_html.rs
index b3e31376737b2d5aabb739f408984a1f5ae0c974..b72130a0d9dea6c8ba1409a0354649a43f398c69 100644
--- a/server/tests/integration_html.rs
+++ b/server/tests/integration_html.rs
@@ -111,25 +111,52 @@ async fn test_vote_compare_renders_github_import_cards() {
     let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;
     let client = reqwest::Client::new();
 
-    let raw = "@00000000-0000-0000-0000-000000000000:test:local/test\n\
-https://github.com/ghvotehi/a/issues/9 {\n\
+    use base64::{engine::general_purpose::STANDARD, Engine as _};
+    let left_card = STANDARD.encode(
+        serde_json::json!({
+            "v": 1,
+            "schema": "slug_github_import",
+            "kind": "issue",
+            "url": "https://github.com/ghvotehi/a/issues/9",
+            "headline": "#9 Left corner",
+            "sublines": ["State: open"],
+        })
+        .to_string()
+        .as_bytes(),
+    );
+    let right_card = STANDARD.encode(
+        serde_json::json!({
+            "v": 1,
+            "schema": "slug_github_import",
+            "kind": "issue",
+            "url": "https://github.com/ghvotehi/a/issues/10",
+            "headline": "#10 Right corner",
+            "sublines": ["State: open"],
+        })
+        .to_string()
+        .as_bytes(),
+    );
+    let raw = format!(
+        "@00000000-0000-0000-0000-000000000000:test:local/test\n\
+https://github.com/ghvotehi/a/issues/9 {{\n\
 ```slug-github-card\n\
-{\"v\":1,\"schema\":\"slug_github_import\",\"kind\":\"issue\",\"url\":\"https://github.com/ghvotehi/a/issues/9\",\"headline\":\"#9 Left corner\",\"sublines\":[\"State: open\"]}\n\
+{left_card}\n\
 ```\n\
-}\n\
+}}\n\
 \n\
-https://github.com/ghvotehi/a/issues/10 {\n\
+https://github.com/ghvotehi/a/issues/10 {{\n\
 ```slug-github-card\n\
-{\"v\":1,\"schema\":\"slug_github_import\",\"kind\":\"issue\",\"url\":\"https://github.com/ghvotehi/a/issues/10\",\"headline\":\"#10 Right corner\",\"sublines\":[\"State: open\"]}\n\
+{right_card}\n\
 ```\n\
-}\n";
+}}\n"
+    );
 
     {
         let mut w = state.reduced.write().await;
         w.apply_event(Event::Ingest(Ingest {
             ts: 10,
             id: "ing-vote-github-cards".to_string(),
-            raw: raw.to_string(),
+            raw,
             principal: "testuser".to_string(),
             delegate: Some(
                 "00000000-0000-0000-0000-000000000000:test:local/test".to_string(),