|
| 1 | +--- |
| 2 | +title: "Augment from Affiliations — the first flow that starts from SurrealDB instead of a CSV, turning an event's speaker/org pairs into a rated, sourced prospect list" |
| 3 | +lede: "Every flow in augment-it so far starts with a CSV — upload, map columns, resolve. This one starts with data already in the canonical layer: pick an event, export every person↔org [[Client-Tagging-on-Canonical-Writes|affiliations]] edge tied to it as a CSV, rate relevance offline in a spreadsheet, reimport — while links and corpus content get added through the existing per-affiliation surface, not a new one. Building the sourced short-list a Reach.Edu team member needs to walk into FreedomFest 2026 and know who's worth a conversation." |
| 4 | +date_created: 2026-07-07 |
| 5 | +date_modified: 2026-07-07 |
| 6 | +authors: |
| 7 | + - Michael Staton |
| 8 | +augmented_with: |
| 9 | + - Claude Code on Claude Sonnet 5 |
| 10 | +semantic_version: 0.0.0.2 |
| 11 | +revisions: |
| 12 | + - 2026-07-07 — v0.0.0.2: split into a hybrid — relevance rating moves to a CSV export/reimport round-trip (bulk-editable, reuses Record Collector's existing upload path); links + corpus point at `person-enrichment`'s existing per-affiliation surface (de-hardcoded from one event) instead of a new worklist UI. Smaller build, more reuse, per operator direction. |
| 13 | + - 2026-07-07 — v0.0.0.1: initial draft — single new all-DB-native worklist app covering rating + links + corpus together. |
| 14 | +status: Draft |
| 15 | +tags: |
| 16 | + - Spec |
| 17 | + - Augment-It |
| 18 | + - Affiliations |
| 19 | + - Canonical-Layer |
| 20 | + - Relevance-Rating |
| 21 | + - Person-Enrichment |
| 22 | + - Org-Resolution |
| 23 | + - CSV-Round-Trip |
| 24 | + - Reach-Edu |
| 25 | + - FreedomFest |
| 26 | +--- |
| 27 | + |
| 28 | +# Augment from Affiliations |
| 29 | + |
| 30 | +## Why this exists |
| 31 | + |
| 32 | +The CEO of Reach.Edu is speaking at FreedomFest 2026. Between now and then, |
| 33 | +someone on the team needs a working list: who else is speaking or attending |
| 34 | +who's worth a conversation about philanthropic fundraising, what |
| 35 | +organization connects them, and enough context on each to make the |
| 36 | +approach make sense. The raw material already exists — 65 speakers |
| 37 | +resolved into `persons`, their organizations resolved into `organizations`, |
| 38 | +61 of them now `RELATE`d by an `affiliations` edge with a role — but |
| 39 | +nothing rates who actually matters, and nothing packages that judgment |
| 40 | +into something shareable. |
| 41 | + |
| 42 | +Every prior augment-it flow (`record-db-resolver`, `person-db-resolver`, |
| 43 | +[[Sparse-Person-Enrichment-Surface]]) starts from an uploaded CSV and |
| 44 | +resolves it into the canonical layer. This is the first flow that runs the |
| 45 | +other direction: the canonical layer already has the data, and the flow's |
| 46 | +job is to **augment what's already there**, not resolve new records in. |
| 47 | + |
| 48 | +**v0.0.0.2 revision:** the first draft designed one new worklist UI to |
| 49 | +cover rating + links + corpus together. Reconsidered — those are two |
| 50 | +different shapes of work, and forcing them into one surface meant building |
| 51 | +more than either needed. Rating 61 rows is a bulk, spreadsheet-shaped task; |
| 52 | +adding links and corpus content is a one-at-a-time, already-solved task. |
| 53 | +Splitting them let most of this spec become "point at what already exists" |
| 54 | +instead of "build a new app." |
| 55 | + |
| 56 | +## User Flow |
| 57 | + |
| 58 | +Two separate loops now, not one. Both operate on the same underlying data |
| 59 | +(the affiliation edge and the person/org it connects), just through |
| 60 | +different surfaces suited to the shape of each task. |
| 61 | + |
| 62 | +### A. Rate relevance — export, edit offline, reimport |
| 63 | + |
| 64 | +1. **Export.** Run the (extended) `scripts/export-event-attendees-csv.mjs` |
| 65 | + for the picked event — it already produces one row per attendee with |
| 66 | + their affiliation (org + role), links, and corpus URLs pulled straight |
| 67 | + from canonical. Add two columns: `relevance` (blank, or pre-filled if |
| 68 | + already rated) and `relevance_note` (blank). Add two more columns that |
| 69 | + exist for reimport, not for editing: `person_uuid` and `org_slug` — the |
| 70 | + pair that identifies exactly which `affiliations` edge a row belongs to. |
| 71 | + |
| 72 | +2. **Edit offline.** The operator opens the CSV in whatever spreadsheet |
| 73 | + tool they already use. Sort, filter, fill down, work in whatever order |
| 74 | + makes sense, share the file with a colleague if a second opinion helps |
| 75 | + — none of that needs to be built, a spreadsheet already does it. Fill |
| 76 | + in `relevance` (one of `Very Relevant` / `Highly Relevant` / `Relevant` |
| 77 | + / `Skip` / `Irrelevant` — see [Data Model Decisions](#data-model-decisions) |
| 78 | + for what each means) and, ideally, `relevance_note` — the eventual |
| 79 | + export needs to say more than a label; "Whole Foods founder, active in |
| 80 | + liberty-movement philanthropy circles, worth a warm intro via X" is |
| 81 | + what actually helps the CEO walk into a conversation prepared. Leave a |
| 82 | + row blank to skip it entirely — that's different from rating it |
| 83 | + `Skip`, see below. |
| 84 | + |
| 85 | +3. **Reimport.** Upload the edited CSV through the existing Record |
| 86 | + Collector path — same generic upload this app already has, no changes |
| 87 | + needed there (columns are always derived from headers, never |
| 88 | + predefined, per this app's standing convention). This creates a |
| 89 | + record-set like any other upload. |
| 90 | + |
| 91 | +4. **Resolve.** A new small resolver step (`affiliation-rating-resolver`, |
| 92 | + details below) works through that record-set's rows: for each, look up |
| 93 | + the `affiliations` edge by `(person_uuid, org_slug)`, and if `relevance` |
| 94 | + is non-blank, write it via a new `affiliation.rate` capability. Same |
| 95 | + per-record-set **column mapping** step [[Sparse-Person-Enrichment-Surface]]'s |
| 96 | + sibling flows already use — the operator confirms once which columns |
| 97 | + are which, not re-typed per row. Rows with blank `relevance` are |
| 98 | + no-ops. Rows whose `relevance` value doesn't match one of the five |
| 99 | + allowed values are **flagged, not silently coerced or dropped** — same |
| 100 | + discipline as everything else touching this canonical layer. |
| 101 | + |
| 102 | +### B. Add links and corpus content — the existing per-affiliation surface |
| 103 | + |
| 104 | +`apps/person-enrichment`'s `AffiliationCard` already does this — an |
| 105 | +operator working one affiliation at a time can add a link (website, |
| 106 | +LinkedIn profile, LinkedIn company page, X, blog, Substack, YouTube, etc. |
| 107 | +— auto-detected from whatever URL they paste) or a corpus entry (a press |
| 108 | +mention, an interview, a bio page — content *about* the person or org, |
| 109 | +not a canonical profile link) to the person side, the org side, or both. |
| 110 | + |
| 111 | +The one real gap: `person-enrichment` currently hardcodes |
| 112 | +`EVENT_SLUG = '2026-05-21-turning-jobs-into-degrees'`. Turning that into |
| 113 | +the same event-picker described in step A.1 (a dropdown of `events` rows, |
| 114 | +client-scoped) is the actual new work here — everything downstream of the |
| 115 | +picker already exists and needs no changes. |
| 116 | + |
| 117 | +### How the two loops relate |
| 118 | + |
| 119 | +Independent and can happen in either order, on different days, by |
| 120 | +different people. Rating doesn't need links/corpus to exist first, and |
| 121 | +vice versa — the export in step A.1 shows whatever links/corpus already |
| 122 | +exist at export time as read context, but doesn't require them. |
| 123 | + |
| 124 | +## Data Model Decisions |
| 125 | + |
| 126 | +### Relevance lives on the `affiliations` edge, not on the person or the org |
| 127 | + |
| 128 | +Considered and rejected: a new table (the user's own first instinct was |
| 129 | +"maybe like `opportunities`?"). Checked — `opportunities` is confirmed |
| 130 | +org-only (no person field anywhere in its schema) and keyed 1:1 to a |
| 131 | +`record_uuid` from a CSV-driven resolution, which this flow's *data* |
| 132 | +doesn't have (only the *rating pass* now round-trips through a CSV — the |
| 133 | +affiliation itself was resolved earlier, by `person-db-resolver`). |
| 134 | +Bending `opportunities` to fit would mean stripping out the one thing |
| 135 | +that makes it `opportunities` — not worth it for a rating. |
| 136 | + |
| 137 | +Considered and rejected: a field directly on `persons` or `organizations`. |
| 138 | +Both tables are multi-tenant (`client_access: string[]` — the same org row |
| 139 | +can be visible to reach-edu *and* humain-vc). A single `relevance` field |
| 140 | +on the row would leak one client's private prioritization to every other |
| 141 | +client who can see that org, or get silently overwritten when two clients |
| 142 | +rate the same row differently. Real correctness bug, not a hypothetical. |
| 143 | + |
| 144 | +**Decision:** add `relevance: string | null` and `relevance_note: string | |
| 145 | +null` directly to the `affiliations` `RELATE` edge, alongside the |
| 146 | +`kind`/`client_access`/`added_at` fields it already carries |
| 147 | +(`person-resolver.ts`'s `applyPersonAffiliation`, current shape). The edge |
| 148 | +is already client-scoped, already uniquely identifies "this person, this |
| 149 | +org, in this relationship," and is exactly what the CSV round-trip's |
| 150 | +`(person_uuid, org_slug)` key resolves to. No new table, no leak risk. |
| 151 | + |
| 152 | +Also add `relevance_rated_by` / `relevance_rated_at` — this app's standing |
| 153 | +actor-attribution pattern (every mutation carries who did it) extends here |
| 154 | +the same way it does everywhere else. |
| 155 | + |
| 156 | +**The five values, precisely:** `Very Relevant` / `Highly Relevant` / |
| 157 | +`Relevant` / `Skip` / `Irrelevant`. `Skip` is a genuine stored rating — |
| 158 | +"reviewed, not worth pursuing *right now*, may revisit" — not the same |
| 159 | +thing as a row the operator never got to (which stays blank/`null` and |
| 160 | +remains open for a future pass). This distinction matters enough to say |
| 161 | +twice: **leaving a CSV cell blank ≠ typing "Skip" into it.** |
| 162 | + |
| 163 | +### Links and corpus need no new data model — and no new capabilities for v0 |
| 164 | + |
| 165 | +`person-enrichment` already writes `personal_links` / `personal_corpus` |
| 166 | +onto `persons`, and `org_links` / `org_corpus` onto `organizations` (via |
| 167 | +`appendOrgLink`/`appendOrgCorpus`/`appendPersonalLink`/`appendPersonalCorpus` |
| 168 | +in its `App.svelte`). Reusing that surface as-is means this spec adds |
| 169 | +zero new fields and zero new capabilities for the link/corpus half of the |
| 170 | +flow. |
| 171 | + |
| 172 | +**Known wart, explicitly not fixed here:** `person-enrichment` writes |
| 173 | +directly to SurrealDB from the client, bypassing the NATS-capability |
| 174 | +gating (`services/workspace/src/capabilities.ts`) every other write path |
| 175 | +in this app goes through. That's pre-existing debt, not introduced by |
| 176 | +this spec, and fixing it is a separate concern — noted so it's not |
| 177 | +mistaken for an oversight. |
| 178 | + |
| 179 | +### The CSV reimport key is `(person_uuid, org_slug)`, never a raw RecordId |
| 180 | + |
| 181 | +Same lesson this codebase already learned twice (`source_uuid` in |
| 182 | +`domains.ts`, `person_uuid` in `person-resolver.ts`): a SurrealDB |
| 183 | +`RecordId` doesn't survive a round-trip through anything outside the |
| 184 | +server — NATS, JSON, and now (more fragile than either) a CSV file a |
| 185 | +human edits by hand in a spreadsheet, which can reformat or mangle text |
| 186 | +in ways JSON never would. `person_uuid` and `org_slug` are both already |
| 187 | +wire-safe, human-stable identifiers used elsewhere in this app. The |
| 188 | +reimport resolver looks each affiliation up fresh by that pair; it never |
| 189 | +trusts a RecordId string surviving the trip. |
| 190 | + |
| 191 | +## Where it lives |
| 192 | + |
| 193 | +**Export:** extend `scripts/export-event-attendees-csv.mjs` with the |
| 194 | +`relevance`/`relevance_note`/`person_uuid`/`org_slug` columns described |
| 195 | +above. Not a new script — this one already pulls affiliation + org + link |
| 196 | ++ corpus data per event, per attendee. |
| 197 | + |
| 198 | +**Reimport + resolve:** a new small remote, |
| 199 | +`apps/affiliation-rating-resolver` (`affiliationRatingResolver` in |
| 200 | +`shell/src/remotes.ts`), port `3012` — the first open gap in the port |
| 201 | +sequence. Much smaller than the v0.0.0.1 draft's worklist app: no |
| 202 | +match-or-create, no candidate search, just column-mapping (reusing the |
| 203 | +established per-record-set mapping pattern) → row iteration → one |
| 204 | +capability call per non-blank `relevance` row. |
| 205 | + |
| 206 | +**Links/corpus:** no new app. `person-enrichment`'s `EVENT_SLUG` constant |
| 207 | +becomes a picker — the smallest change in this spec. |
| 208 | + |
| 209 | +One new capability, in `services/record-surrealdb-resolver/src/person-resolver.ts`, |
| 210 | +registered in `CAPABILITY_TO_SUBJECT` per the existing gating discipline: |
| 211 | + |
| 212 | +| Capability | What it does | |
| 213 | +|---|---| |
| 214 | +| `affiliation.rate` | Given `(person_uuid, org_slug, relevance, relevance_note)`, look up the live `affiliations` edge fresh and set `relevance`/`relevance_note`/`relevance_rated_by`/`relevance_rated_at`. Throws (surfaced to the operator, not silently dropped) if no matching edge exists. | |
| 215 | + |
| 216 | +## Composes with |
| 217 | + |
| 218 | +- [[Client-Tagging-on-Canonical-Writes]] — the export query and the new |
| 219 | + `relevance` fields respect `client_access` the same way every other |
| 220 | + canonical write does. See `surrealdb-canonical-layer` (lossless-skills) |
| 221 | + for the per-table shape reference. |
| 222 | +- [[Sparse-Person-Enrichment-Surface]] — the per-record-set column-mapping |
| 223 | + pattern the reimport resolver reuses, and the event-scoped-worklist |
| 224 | + discipline the export step follows. |
| 225 | +- `context-v/plans/Person-Aware-Canonical-Resolver-Extension.md` — the |
| 226 | + person/org/affiliation split this flow's data model builds directly on |
| 227 | + top of. |
| 228 | +- `context-v/plans/SurrealDB-MCP-Plus-Skill-for-Canonical-Layer-Verification.md` |
| 229 | + — the verification pattern that surfaced, on 2026-07-07, that 61 of 65 |
| 230 | + FreedomFest speakers now have exactly the affiliation edges this flow's |
| 231 | + export depends on. |
| 232 | +- `scripts/export-event-attendees-csv.mjs`, `scripts/export-event-briefing.mjs`, |
| 233 | + and their sibling `export-branded-briefing.mjs` (markdown + brand config |
| 234 | + → branded HTML/PDF) — the export side of this spec extends the first; |
| 235 | + the eventual CEO-brief export (out of scope here) has a natural home in |
| 236 | + the third once rating data exists to feed it. |
| 237 | + |
| 238 | +## Out of scope for v0.0.0.2 |
| 239 | + |
| 240 | +- **The CEO-brief export itself.** Real, wanted, explicitly deferred — |
| 241 | + once `relevance`/`relevance_note` exist on every affiliation, that |
| 242 | + export is a read-only view over data this spec already produces, |
| 243 | + plausibly built on `export-branded-briefing.mjs`'s existing |
| 244 | + markdown-plus-brand-config → HTML/PDF path. A much smaller spec to |
| 245 | + write later, with real data to design against instead of guesses. |
| 246 | +- **Rating a person or org independent of a specific affiliation.** |
| 247 | + Someone might eventually want "this org is relevant regardless of which |
| 248 | + person," but nothing in the current use case asks for it, and it |
| 249 | + reopens the multi-tenant-leak problem the affiliation-scoped design |
| 250 | + avoids. Wait for a real case. |
| 251 | +- **Bulk / automated relevance scoring.** An LLM could plausibly draft a |
| 252 | + first-pass rating from a person's headline + org + corpus content. Not |
| 253 | + this spec — manual-first, automate once the manual pattern is proven. |
| 254 | +- **Multi-event exports.** One event at a time, same discipline |
| 255 | + [[Sparse-Person-Enrichment-Surface]] already settled on. |
| 256 | +- **Fixing `person-enrichment`'s direct-SurrealDB write path.** Flagged |
| 257 | + above as a known wart. Pre-existing, not created by this spec, not |
| 258 | + blocking it. |
| 259 | + |
| 260 | +## Open questions |
| 261 | + |
| 262 | +- Should `relevance`'s five values also gate what shows up in a future |
| 263 | + export by default (e.g. blank and `Skip` excluded, `Relevant` and up |
| 264 | + included)? Reasonable default, not decided — the export spec's problem. |
| 265 | +- `personal_links`/`personal_corpus` naming vs. `org_links`/`org_corpus` — |
| 266 | + still a live inconsistency, still not fixed here. |
| 267 | +- Is a script + CSV-upload round-trip the permanent shape for the rating |
| 268 | + pass, or does it earn a proper in-app export/download button once the |
| 269 | + pattern proves out? Leaning toward "stays a script" per this app's |
| 270 | + manual-first-then-automate discipline, but not decided. |
| 271 | + |
| 272 | +## See also |
| 273 | + |
| 274 | +- [[Sparse-Person-Enrichment-Surface]] — column-mapping and event-scoping |
| 275 | + patterns reused here |
| 276 | +- [[Client-Tagging-on-Canonical-Writes]] — multi-tenant discipline this |
| 277 | + spec's data-model decisions are built around |
| 278 | +- `context-v/plans/Person-Aware-Canonical-Resolver-Extension.md` — the |
| 279 | + person/org/affiliation schema this flow is additive on top of |
| 280 | +- `scripts/export-event-attendees-csv.mjs` — the export this spec extends |
0 commit comments