Skip to content

Commit 62ab198

Browse files
mpstatonclaude
andcommitted
test(org-relations), fix(relation-update): proof script lands 22/22 green after catching two real bugs
The acceptance proof for the org-relations capabilities exists and runs all-green — and its first run immediately paid for itself by catching two real defects in relation.update before any UI touched the verb. scripts/prove-org-relations.mjs: mints three throwaway orgs under a throwaway client slug (invisible to real workspaces even mid-run), proves relate / trichotomy-from-both-perspectives / duplicate-pair rejection / peer / flip / unrelate / tag add-dedup-detail-remove over NATS, runs the client-tagging audit per the surrealdb-canonical-layer discipline (re-query WITHOUT the client filter, inspect client_access per row), and deletes down to zero residue via direct SurrealDB (no org-delete verb on the wire, deliberately). Bugs fixed in org-relations.ts relation.update: - $access is a protected SurrealDB variable — the carried client_access now binds as $carried. - The direction flip deleted the old edge BEFORE the new RELATE; a RELATE failure destroyed the relation. Reordered create-new-then-delete-old so failure leaves the original edge intact. Refs #51. Files changed: - scripts/prove-org-relations.mjs (new) - services/record-surrealdb-resolver/src/org-relations.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 cedf12b commit 62ab198

3 files changed

Lines changed: 185 additions & 4 deletions

File tree

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,25 @@ registrations, and three live NATS checks pass (empty trichotomy read on
9393
`the-aspen-institute`, self-relation guard → localized `ok:false`,
9494
`detail.org.tags` present). Full write-path proof is the proof script's
9595
job (#51).
96+
97+
### The proof script — 22 checks, and it earned its keep immediately (#51)
98+
99+
`scripts/prove-org-relations.mjs` mints three throwaway orgs under a
100+
throwaway client slug (invisible to every real workspace even mid-run),
101+
proves the full write path over NATS — relate/trichotomy-from-both-sides/
102+
duplicate-rejection/peer/flip/unrelate/tags — then runs the
103+
surrealdb-canonical-layer client-tagging audit (re-query **without** the
104+
client filter, inspect `client_access` on every row) and deletes down to
105+
zero residue.
106+
107+
First run caught two real bugs in `relation.update`:
108+
109+
1. **`$access` is a protected SurrealDB variable** — binding the carried
110+
`client_access` under that name threw
111+
`'access' is a protected variable and cannot be set`.
112+
2. **Destructive order** — the flip deleted the old edge *before* the
113+
RELATE that then failed, silently destroying the relation. Reordered to
114+
create-new-then-delete-old, so a failed RELATE now leaves the original
115+
edge intact.
116+
117+
Second run: 22/22 green, `cleanup: zero residue`.

scripts/prove-org-relations.mjs

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
#!/usr/bin/env node
2+
// prove-org-relations.mjs — acceptance proof for the org-relations feature
3+
// (context-v/plans/Org-Relations-Parent-Child-Peer-Plus-Org-Tags.md, gh #51).
4+
//
5+
// Capability checks ride NATS like every prove-* script; the final
6+
// client-tagging audit + zero-residue cleanup talk to SurrealDB directly
7+
// (the surreal-backfill-* precedent), because there is deliberately no
8+
// org-delete verb on the wire.
9+
//
10+
// Everything is minted under a throwaway client slug, so even mid-run the
11+
// test rows are invisible to every real workspace read.
12+
//
13+
// Prereqs: docker compose up -d nats record-surrealdb-resolver
14+
// Usage: set -a; source ./.env; set +a
15+
// node scripts/prove-org-relations.mjs
16+
17+
import { createRequire } from 'node:module';
18+
import { Surreal } from 'surrealdb';
19+
const require = createRequire(new URL('../services/social-search/package.json', import.meta.url));
20+
const { connect } = require('@nats-io/transport-node');
21+
22+
const NATS_URL = process.env.NATS_URL ?? 'nats://localhost:4222';
23+
const CLIENT = 'proof-org-relations';
24+
const STAMP = process.pid; // uniquify slugs across runs without Date.now()
25+
const A = `proof-child-org-${STAMP}`;
26+
const B = `proof-parent-org-${STAMP}`;
27+
const C = `proof-peer-org-${STAMP}`;
28+
29+
const nc = await connect({ servers: NATS_URL });
30+
const req = async (subject, body, timeout = 30_000) =>
31+
JSON.parse(new TextDecoder().decode((await nc.request(subject, JSON.stringify(body), { timeout })).data));
32+
let failed = 0;
33+
const check = (label, ok, extra = '') => { console.log(`${ok ? '✅' : '❌'} ${label} ${extra}`); if (!ok) failed++; };
34+
35+
// --- mint three throwaway orgs (org-only person.affiliate, the documented
36+
// no-person path: creates the org row, no edge) ------------------------------
37+
for (const slug of [A, B, C]) {
38+
const r = await req('person.affiliate.requested', {
39+
org_action: 'create', org_name: slug, client: CLIENT, source: 'prove-org-relations',
40+
});
41+
check(`mint ${slug}`, r.ok && r.org_created && r.org_slug === slug, r.ok ? '' : r.error);
42+
}
43+
44+
// (a) relate parent — from A's perspective, B is the parent
45+
const rel1 = await req('organization.relate.requested', {
46+
org_slug: A, other_slug: B, rel: 'parent', kind: 'initiative_of',
47+
description: 'proof: A is an initiative of B', client: CLIENT,
48+
});
49+
check('relate A→parent B', rel1.ok && rel1.created === true && rel1.rel === 'parent', rel1.ok ? '' : rel1.error);
50+
51+
// (b) trichotomy from BOTH perspectives + kind/description round-trip
52+
const relA = await req('organization.relations.requested', { org_slug: A, client: CLIENT });
53+
check('A.parents = [B] w/ kind+description',
54+
relA.ok && relA.parents.length === 1 && relA.parents[0].slug === B
55+
&& relA.parents[0].kind === 'initiative_of' && /initiative of B/.test(relA.parents[0].description ?? ''),
56+
relA.ok ? JSON.stringify(relA.parents) : relA.error);
57+
const relB = await req('organization.relations.requested', { org_slug: B, client: CLIENT });
58+
check('B.children = [A]', relB.ok && relB.children.length === 1 && relB.children[0].slug === A
59+
&& relB.parents.length === 0 && relB.peers.length === 0);
60+
61+
// (c) duplicate-pair rejection → created:false, not an error, not a second edge
62+
const dup = await req('organization.relate.requested', {
63+
org_slug: B, other_slug: A, rel: 'child', client: CLIENT,
64+
});
65+
check('duplicate pair → created:false', dup.ok && dup.created === false);
66+
67+
// (d) peer relation, visible from both sides
68+
const rel2 = await req('organization.relate.requested', {
69+
org_slug: A, other_slug: C, rel: 'peer', kind: 'partners_with', client: CLIENT,
70+
});
71+
check('relate A—peer—C', rel2.ok && rel2.created === true);
72+
const relA2 = await req('organization.relations.requested', { org_slug: A, client: CLIENT });
73+
const relC = await req('organization.relations.requested', { org_slug: C, client: CLIENT });
74+
check('peer from both perspectives',
75+
relA2.ok && relA2.peers.length === 1 && relA2.peers[0].slug === C
76+
&& relC.ok && relC.peers.length === 1 && relC.peers[0].slug === A);
77+
78+
// (e) relation.update — flip A/B (B becomes A's child) + patch description
79+
const upd = await req('organization.relation.update.requested', {
80+
org_slug: A, other_slug: B, rel: 'child', description: 'proof: flipped', client: CLIENT,
81+
});
82+
check('relation.update flip → rel:child', upd.ok && upd.rel === 'child', upd.ok ? '' : upd.error);
83+
const relA3 = await req('organization.relations.requested', { org_slug: A, client: CLIENT });
84+
check('post-flip: A.children = [B], A.parents = []',
85+
relA3.ok && relA3.children.length === 1 && relA3.children[0].slug === B
86+
&& relA3.parents.length === 0 && /flipped/.test(relA3.children[0].description ?? ''));
87+
88+
// (f) zero contamination — the People Reveal read sees no org→org edges
89+
const aff = await req('organization.affiliations.requested', { org_slug: A, client: CLIENT });
90+
check('organization.affiliations sees 0 people (no org_org bleed)',
91+
aff.ok && Array.isArray(aff.people) && aff.people.length === 0);
92+
93+
// (g) unrelate → gone
94+
const unrel = await req('organization.unrelate.requested', { org_slug: A, other_slug: B, client: CLIENT });
95+
check('unrelate A↔B removed=1', unrel.ok && unrel.removed === 1);
96+
const relA4 = await req('organization.relations.requested', { org_slug: A, client: CLIENT });
97+
check('post-unrelate: only the peer remains',
98+
relA4.ok && relA4.children.length === 0 && relA4.parents.length === 0 && relA4.peers.length === 1);
99+
100+
// (h) tags — add (toDashed: dashes-not-spaces, casing preserved), dedup, detail, remove
101+
const tag1 = await req('organization.tag.add.requested', { org_slug: A, tag: 'Proof Initiative', client: CLIENT });
102+
check("tag.add 'Proof Initiative' → 'Proof-Initiative'", tag1.ok && tag1.tag === 'Proof-Initiative' && tag1.created === true);
103+
const tag2 = await req('organization.tag.add.requested', { org_slug: A, tag: 'Proof-Initiative', client: CLIENT });
104+
check('tag.add duplicate → created:false', tag2.ok && tag2.created === false);
105+
const det = await req('organization.detail.requested', { org_slug: A, client: CLIENT });
106+
check('detail.org.tags = [Proof-Initiative]', det.ok && det.org.tags.length === 1 && det.org.tags[0] === 'Proof-Initiative');
107+
const tagRm = await req('organization.tag.remove.requested', { org_slug: A, tag: 'Proof-Initiative', client: CLIENT });
108+
const det2 = await req('organization.detail.requested', { org_slug: A, client: CLIENT });
109+
check('tag.remove → detail.org.tags = []', tagRm.ok && tagRm.removed === true && det2.ok && det2.org.tags.length === 0);
110+
111+
await nc.drain();
112+
113+
// --- client-tagging audit (per the surrealdb-canonical-layer discipline:
114+
// re-query WITHOUT the client filter, inspect the field on every row) — then
115+
// zero-residue cleanup ---------------------------------------------------------
116+
const db = new Surreal();
117+
await db.connect(process.env.SURREAL_URL);
118+
await db.signin({ username: process.env.SURREAL_USER, password: process.env.SURREAL_PASS });
119+
await db.use({ namespace: process.env.SURREAL_NS, database: process.env.SURREAL_DB });
120+
121+
const orgs = (await db.query(
122+
`SELECT id, slug, client_access FROM organizations WHERE slug IN [$a, $b, $c];`,
123+
{ a: A, b: B, c: C },
124+
))?.[0] ?? [];
125+
check('audit: 3 proof orgs, each client_access == [proof client]',
126+
orgs.length === 3 && orgs.every((o) => Array.isArray(o.client_access) && o.client_access.length === 1 && o.client_access[0] === CLIENT),
127+
JSON.stringify(orgs.map((o) => ({ slug: o.slug, client_access: o.client_access }))));
128+
129+
const orgIds = orgs.map((o) => o.id);
130+
const edges = (await db.query(
131+
`SELECT id, rel, client_access FROM affiliations WHERE edge_type = 'org_org' AND (in IN $ids OR out IN $ids);`,
132+
{ ids: orgIds },
133+
))?.[0] ?? [];
134+
check('audit: surviving edge (the peer) carries client_access == [proof client]',
135+
edges.length === 1 && edges[0].client_access?.length === 1 && edges[0].client_access[0] === CLIENT,
136+
`edges=${edges.length}`);
137+
138+
const obs = (await db.query(
139+
`SELECT id, predicate, client FROM observations WHERE subject IN $ids;`,
140+
{ ids: orgIds },
141+
))?.[0] ?? [];
142+
check('audit: every proof observation carries client (singular) == proof client',
143+
obs.length > 0 && obs.every((o) => o.client === CLIENT), `n=${obs.length}`);
144+
145+
// cleanup — edges first, then observations, then the rows
146+
await db.query(`DELETE affiliations WHERE edge_type = 'org_org' AND (in IN $ids OR out IN $ids);`, { ids: orgIds });
147+
await db.query(`DELETE observations WHERE subject IN $ids OR object IN $ids;`, { ids: orgIds });
148+
await db.query(`DELETE organizations WHERE slug IN [$a, $b, $c];`, { a: A, b: B, c: C });
149+
const leftover = (await db.query(
150+
`SELECT id FROM organizations WHERE slug IN [$a, $b, $c];`, { a: A, b: B, c: C },
151+
))?.[0] ?? [];
152+
check('cleanup: zero residue', leftover.length === 0);
153+
await db.close();
154+
155+
console.log(failed ? `\n${failed} check(s) failed` : '\nall green');
156+
process.exit(failed ? 1 : 0);

services/record-surrealdb-resolver/src/org-relations.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -283,14 +283,17 @@ export async function updateOrgRelation(
283283
String(edge.in) !== String(wantIn) || String(edge.out) !== String(wantOut);
284284

285285
if (directionChanges || wantStoredRel !== edge.rel) {
286-
const access = Array.from(new Set([...(edge.client_access ?? []), input.client]));
287-
await db.query('DELETE $id;', { id: edge.id });
286+
// $access is a protected SurrealDB variable — bind under another name.
287+
// New edge first, old edge second: if the RELATE fails, the relation
288+
// survives instead of vanishing.
289+
const carried = Array.from(new Set([...(edge.client_access ?? []), input.client]));
288290
await db.query(
289291
`RELATE $child->affiliations->$parent SET
290292
edge_type = 'org_org', rel = $rel, kind = $kind, description = $description,
291-
client_access = $access, added_at = time::now();`,
292-
{ child: wantIn, parent: wantOut, rel: wantStoredRel, kind, description, access },
293+
client_access = $carried, added_at = time::now();`,
294+
{ child: wantIn, parent: wantOut, rel: wantStoredRel, kind, description, carried },
293295
);
296+
await db.query('DELETE $id;', { id: edge.id });
294297
} else {
295298
await db.query('UPDATE $id SET kind = $kind, description = $description;', {
296299
id: edge.id,

0 commit comments

Comments
 (0)