Your company network may have a service provider at its centre, just as you're about to publish it as a map of common ownership.
The join looks persuasive: matching names, matching addresses, a dense cluster of filings. But registeredAgentName records a different relationship from officers or authorizedPersons. Collapsing them into a single person-to-company edge changes what the graph claims.
A shared registered agent alone doesn't establish shared ownership or control.
What does a company record actually represent?
The Sunbiz lookup Actor returns a filing profile for each result. Its documentNumber identifies the filing; entityName supplies the registered name. Neither identifies a person.
That distinction matters before you draw anything. A trademark filing can also appear among the results, but its owner owns the mark. That relationship doesn't establish ownership of businesses with similar names.
Keep the filing between the relationship and your interpretation:
registered-agent name ── receives process for ── business filing
officer name ── holds listed office in ── business filing
authorised name ── has listed LLC role in ── business filing
trademark owner ── owns mark in ─────────── trademark filing
Registered agents receive service of process. They can be organisations, so calling every node a “person” introduces another assumption before the ownership question even arrives.
A broad search isn't a relationship classification
The officerOrRegisteredAgent lookup can bring unlike connections into the same search result set. The lookup explains how a record arrived; the relationship fields explain what it says.
Name indexes also return nearest entries when there's no exact match. Only documentNumber is a true exact lookup. Search membership therefore needs checking before graph membership.
Example input below uses a broad name lookup and leaves filing types unrestricted so that the subsequent checks remain visible.
import os
import json
import unicodedata
import pandas as pd
import duckdb
from apify_client import ApifyClient
run_input = {
"searchType": "officerOrRegisteredAgent",
"searchTerms": ["smith john"],
"entityType": "any",
"includeEventHistory": False,
"mirrorFiledDocuments": "none",
"maxItems": 25,
}
client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor(
"dataspryva/florida-sunbiz-registry-lookup-by-name-agent"
).call(run_input=run_input)
if run["status"] != "SUCCEEDED":
raise RuntimeError(run["status"])
items = []
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
items.append(item)
maxItems caps emitted records across the entire input. An apparent end to the network can therefore be an extraction boundary.
The resulting population is the returned filings from this lookup, subject to that cap. It isn't every client, appointment or business associated with the searched name.
The same object shape hides different relationships
Here are the fields that determine the graph's meaning. The nested structures are specified; scalar address layouts and title vocabularies aren't.
| Field | Value and availability | Analytical treatment |
|---|---|---|
documentNumber |
Sunbiz filing identifier; no fixed lexical pattern specified | Nullable string key, never a quantity |
entityName, entityType
|
Registered name and published filing-type text | Labels and filing context, not identity evidence |
registeredAgentName |
Agent name; omitted when empty, absent from trademark filings | Agent relationship, potentially an organisation |
registeredAgentAddress |
Published agent address; internal format unspecified | Preserve the value; shared premises do not resolve identity |
officers |
Array of {title, name, address} for corporate and non-profit officers/directors; empty fields omitted |
Corporate office relationships |
authorizedPersons |
Same object shape for Florida LLC managers, members or other authorised persons; empty fields omitted | Preserve the particular listed role |
Nested title, name, address
|
Role text, display name and address; no title enumeration or address subfields specified | Keep raw text alongside any comparison keys |
owners |
Trademark array of {name, address}, included when present |
A separate mark-ownership relationship |
The convenient flattening operation is also the dangerous one. Identical columns make these lists easy to concatenate; they don't make their relationships interchangeable.
An LLC entry can describe membership, management or another authorised role. Preserve title rather than translating every entry into “owner”. Corporate office, management authority and equity ownership are different claims.
Type identifiers and preserve awkward addresses
Treat filing identifiers as nullable strings on ingestion. Numeric conversion invites arithmetic, lost formatting and joins against incompatible identifiers.
Conversion can't restore leading characters already lost upstream.
This cell creates a filing table and conservative text helpers while retaining the original records in items.
records = pd.DataFrame({
"record_row": range(len(items)),
"documentNumber": pd.array(
[item.get("documentNumber") for item in items],
dtype="string",
),
"entityName": pd.array(
[item.get("entityName") for item in items],
dtype="string",
),
})
def text_or_na(value):
return value if isinstance(value, str) and value.strip() else pd.NA
def comparison_name(value):
if not isinstance(value, str):
return pd.NA
normal = unicodedata.normalize("NFC", value)
return " ".join(normal.casefold().split()) or pd.NA
The comparison key changes case and whitespace. It leaves punctuation, initials and word order alone.
That restraint is deliberate. Removing more characters can manufacture agreement between different names. Even exact display-text agreement remains a candidate match, not a resolved identity.
For addresses, we'd rather retain an awkward value than invent a street-and-postcode schema. JSON encoding preserves its supplied structure, while an explicit presence flag distinguishes an omitted key from a published null.
Missing relationships change the denominator
Empty fields are omitted. Older, inactive and certain filing types can lack values on file; trademarks don't have agents, officers or standard business addresses.
Consequently, an omitted list can't safely become an empty list for an average. That would convert “not supplied here” into “has no listed relationships” and mix filing types with different reporting structures.
The loader below builds business-role edges and an availability ledger. It leaves trademark ownership in the original records for a separate mark analysis.
edge_rows = []
audit_rows = []
for record_row, item in enumerate(items):
for relationship_source in (
"registeredAgentName", "officers", "authorizedPersons"
):
value = item.get(relationship_source)
entries = []
if relationship_source not in item:
state = "omitted"
elif value is None:
state = "null"
elif relationship_source == "registeredAgentName":
if isinstance(value, str) and value.strip():
state = "present"
entry = {"name": value}
if "registeredAgentAddress" in item:
entry["address"] = item["registeredAgentAddress"]
entries = [entry]
else:
state = "unexpected_or_blank"
elif isinstance(value, list):
state = "present" if value else "empty_list"
entries = value
else:
state = "unexpected_type"
objects = [entry for entry in entries if isinstance(entry, dict)]
named_entries = 0
for entry in objects:
raw_name = text_or_na(entry.get("name"))
named_entries += int(pd.notna(raw_name))
edge_rows.append({
"record_row": record_row,
"documentNumber": records.at[record_row, "documentNumber"],
"relationship_source": relationship_source,
"title": text_or_na(entry.get("title")),
"name": raw_name,
"name_key": comparison_name(raw_name),
"address_present": "address" in entry,
"address_json": (
json.dumps(entry["address"], ensure_ascii=False)
if "address" in entry else pd.NA
),
})
audit_rows.append({
"record_row": record_row,
"relationship_source": relationship_source,
"state": state,
"named_entries": named_entries,
"unparsed_entries": len(entries) - len(objects),
})
relations = pd.DataFrame(edge_rows, columns=[
"record_row", "documentNumber", "relationship_source", "title",
"name", "name_key", "address_present", "address_json",
]).astype({
"record_row": "int64",
"documentNumber": "string",
"relationship_source": "string",
"title": "string",
"name": "string",
"name_key": "string",
"address_present": "boolean",
"address_json": "string",
})
audit = pd.DataFrame(audit_rows, columns=[
"record_row", "relationship_source", "state",
"named_entries", "unparsed_entries",
]).astype({
"record_row": "int64",
"relationship_source": "string",
"state": "string",
"named_entries": "int64",
"unparsed_entries": "int64",
})
Unexpected list entries stay visible in the ledger rather than becoming invented people. Objects without a usable name remain in the edge table but can't participate in name matching.
This cell separates availability counts from the average among supplied, structurally readable lists.
availability = (
audit.groupby(["relationship_source", "state"], dropna=False)
.size()
.rename("record_count")
.reset_index()
)
readable_lists = audit.loc[
audit["relationship_source"].isin(["officers", "authorizedPersons"])
& audit["state"].isin(["present", "empty_list"])
& audit["unparsed_entries"].eq(0)
]
list_statistics = (
readable_lists.groupby("relationship_source")
.agg(
denominator_records=("record_row", "size"),
mean_named_entries=("named_entries", "mean"),
)
.reset_index()
)
Call that metric “mean named entries among supplied, readable lists”. It counts entries, including repeated names; it doesn't count distinct people.
Report its denominator alongside the availability table. Without filing-type-specific eligibility checks, the omitted category combines structurally inapplicable fields with unavailable information.
Compare like relationships before drawing links
DuckDB can assemble the graph table without erasing the source field. UNION ALL retains the observations; deduplication belongs to the particular question you ask next.
This cell adds relationship labels while preserving raw titles and addresses.
con = duckdb.connect()
con.register("relations", relations)
edges = con.sql("""
SELECT *, 'registered_agent' AS relationship_kind
FROM relations
WHERE relationship_source = 'registeredAgentName'
UNION ALL
SELECT *, 'corporate_office' AS relationship_kind
FROM relations
WHERE relationship_source = 'officers'
UNION ALL
SELECT *, 'llc_authorized_role' AS relationship_kind
FROM relations
WHERE relationship_source = 'authorizedPersons'
""").df()
con.register("edges", edges)
Now compare repeated names within each relationship. The next query counts distinct filing identifiers per comparison name, so duplicate rows or repeated appointments don't inflate that particular count.
shared_names = con.sql("""
WITH memberships AS (
SELECT DISTINCT
relationship_kind,
name_key,
documentNumber
FROM edges
WHERE name_key IS NOT NULL
AND documentNumber IS NOT NULL
AND trim(documentNumber) <> ''
)
SELECT
relationship_kind,
name_key,
count(*) AS filing_count
FROM memberships
GROUP BY relationship_kind, name_key
HAVING count(*) > 1
ORDER BY relationship_kind, filing_count DESC, name_key
""").df()
con.close()
The resulting groups are publication leads: repeated agent names, repeated officer names and repeated LLC authorised-person names. Missing filing identifiers are excluded from this comparison and need a separate count before you report its coverage.
A name appearing in different relationship categories remains separate here. Combining those categories requires an explicit analytical decision and evidence that the names identify the same party.
Where the ownership claim runs out of evidence
Florida Sunbiz Registry Lookup by Name, Agent & FEI supplies filing relationships. Turning them into beneficial ownership, personal identity or practical control requires further evidence.
Names and addresses can support an investigation, but neither is a reliable identity key on its own. Shared offices, service providers and namesakes can all produce plausible-looking connections.
Timing needs similar care. A collection of current filing profiles doesn't establish that every appointment overlapped historically. Dates attached to filing events don't automatically date every relationship in the profile.
For a publishable chart, keep the edge language as narrow as the evidence: “lists the same registered-agent name”, “lists the same officer name”, or “lists the same LLC authorised-person name”. State the search boundary and distinguish unresolved name matches from identified parties.
The graph can show a connection perfectly and still give it the wrong name.
Top comments (0)