Skip to content

Commit cedf12b

Browse files
mpstatonclaude
andcommitted
feat(org-relations, org-tags): org↔org edges + org tags land as six capabilities
Organizations can finally know about each other: parent/child/peer relations with a typed kind and free-text description, plus the first org-level tag mechanism (has_tag observations + the existing tag_vocab). Everything reachable over the wire — no direct DB writes needed. New module services/record-surrealdb-resolver/src/org-relations.ts serves organization.relate / relations / unrelate / relation.update and organization.tag.add / tag.remove. Org→org edges ride the SAME affiliations RELATE table as person→org edges with an explicit edge_type='org_org' discriminator; canonical direction in=child/out=parent, stored rel 'child_of'|'peer', parent/child/peer projected at read time relative to the focused org. One relation per pair (both-direction dedup, client_access union on re-relate). related_to / relation_removed observations for the audit trail. resolver.ts: organization.detail now returns tags: string[] (per-client has_tag observations — never fields on the shared multi-tenant org row). domains.ts: ensureTagInVocab exported for reuse; tags keep the house toDashed behavior (dashes-not-spaces, operator owns casing). capabilities.ts: six verb→subject entries + 30s timeouts. Verified: tsc clean on both services; resolver boots with the new registrations; live NATS checks — relations trichotomy read, self-relate guard localized ok:false, detail.org.tags present. Refs #49 #50 (one module, one commit — tags and relations share the slab). Files changed: - services/record-surrealdb-resolver/src/org-relations.ts (new) - services/record-surrealdb-resolver/src/resolver.ts - services/record-surrealdb-resolver/src/domains.ts - services/record-surrealdb-resolver/src/server.ts - services/workspace/src/capabilities.ts - changelog/2026-07-27_01_Organizations-Learn-Their-Family-Tree-Parent-Child-Peer-Relations-Plus-Org-Tags.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RW28dw3kQAKXr2ZNefCukE
1 parent 570d0b6 commit cedf12b

6 files changed

Lines changed: 480 additions & 1 deletion

File tree

changelog/2026-07-27_01_Organizations-Learn-Their-Family-Tree-Parent-Child-Peer-Relations-Plus-Org-Tags.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,50 @@ and the issue that demanded it,
4646
## What landed
4747

4848
<!-- one beat per closed ticket — step, code sample of the interesting part, gotchas -->
49+
50+
### The capability slab: six new verbs, one new module (#49, #50)
51+
52+
`services/record-surrealdb-resolver/src/org-relations.ts` is the whole
53+
backend: `organization.relate / relations / unrelate / relation.update`
54+
plus `organization.tag.add / tag.remove`, registered domains.ts-style and
55+
mapped through the workspace verb table. Org→org edges live in the same
56+
`affiliations` RELATE table as person→org edges, discriminated explicitly:
57+
58+
```sql
59+
RELATE $child->affiliations->$parent SET
60+
edge_type = 'org_org', rel = $rel, kind = $kind, description = $description,
61+
client_access = [$client], added_at = time::now();
62+
```
63+
64+
The parent/child/peer trichotomy the operator speaks is a read-time
65+
projection — canonical direction is always `in` = child, `out` = parent,
66+
and `projectRel()` names the edge from whichever org you're looking at:
67+
68+
```ts
69+
function projectRel(edge: PairEdge, focused: unknown): OrgRelKind {
70+
if (edge.rel === 'peer') return 'peer';
71+
return String(edge.in) === String(focused) ? 'parent' : 'child';
72+
}
73+
```
74+
75+
One relation per org pair (dedup scans both directions; an existing edge
76+
unions `client_access` and reports `created: false`, the `person.affiliate`
77+
precedent — not an error). A parent↔child flip in `relation.update`
78+
re-normalizes by delete + re-relate, because RELATE edges can't swap
79+
`in`/`out` in place.
80+
81+
Org tags are `has_tag` observations (subject = org RecordId, per-client),
82+
never fields on the shared org row — the same multi-tenant rationale that
83+
put `relevance` on the affiliation edge. `organization.detail` now returns
84+
`tags: string[]`. Two gotchas worth recording: `tag.suggest`/`tag.apply`
85+
already existed (so no new vocab verb — the datalist rides `tag.suggest`,
86+
and the handlers reuse `ensureTagInVocab`, newly exported), and the house
87+
tag normalizer `toDashed` deliberately **preserves operator casing**
88+
("Impact of AI" → "Impact-of-AI"), so Train-Case lives in the vocabulary
89+
convention, not a forced normalizer.
90+
91+
Verified: both services typecheck clean, resolver boots with the new
92+
registrations, and three live NATS checks pass (empty trichotomy read on
93+
`the-aspen-institute`, self-relation guard → localized `ok:false`,
94+
`detail.org.tags` present). Full write-path proof is the proof script's
95+
job (#51).

services/record-surrealdb-resolver/src/domains.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ async function applyBibToRegistry(
116116
if (set.length) await db.query(`UPDATE sources SET ${set.join(', ')} WHERE source_uuid = $u;`, vars);
117117
}
118118

119-
async function ensureTagInVocab(db: Surreal, client_slug: string, tag: string): Promise<void> {
119+
export async function ensureTagInVocab(db: Surreal, client_slug: string, tag: string): Promise<void> {
120120
if (!tag) return;
121121
const seen = first<{ tag: string }>(
122122
await db.query('SELECT tag FROM tag_vocab WHERE client_slug = $c AND tag = $tag LIMIT 1', { c: client_slug, tag }),

0 commit comments

Comments
 (0)