Skip to content

feat(core)!: discriminated target union on relationship associations - #207

Merged
cuibonobo merged 9 commits into
mainfrom
claude/design-issues-15-16-plan-rwnori
Aug 30, 2026
Merged

feat(core)!: discriminated target union on relationship associations#207
cuibonobo merged 9 commits into
mainfrom
claude/design-issues-15-16-plan-rwnori

Conversation

@cuibonobo

@cuibonobo cuibonobo commented Aug 29, 2026

Copy link
Copy Markdown
Member

Summary

Implements #16, and settles #15 by dissolving it.

A relationship's target now names which identifier space its value belongs to, rather than assuming every reference is a Record id in this stack:

type RelationshipTarget =
  | { scope: 'record'; recordId: RecordId; stackUrl?: string }
  | { scope: 'entity'; entityId: EntityId }
  | { scope: 'external'; ns: string; id: string };

Three arms rather than the two #16 proposes, because of a finding that changes the case for this change:

Group rosters already break the current type. Membership on a _group record stores a member's DID in a field typed RecordIddocs/spec/identity.md said so outright — and groupRoleFromAssociations() compared the two as plain strings. Both are string, so nothing caught it, in code that decides group-based reads. An internal/external union would have left that in place and tagged it scope: 'internal', asserting the DID is a record in this stack. The entity arm fixes it, and a roster entry carrying a record target now confers nothing even when its value equals a member's DID.

That also takes this off the ATProto dependency chain: it is a correctness fix in the identity model, with the cross-protocol reference case arriving for free.

RecordFilter.relatedTo moves with it, or the union is half-writable — you could store a target the query engine cannot address. It names a label, a target, or both, and each is a pattern:

Filter Matches
{ label: 'series' } every target under that label
{ target: { scope: 'external', ns: 'atproto' } } a whole namespace
{ target: { scope: 'entity', entityId } } every record naming a person
{ label, target } both, intersected

The first two were not expressible before — recordId was required. A record target with no stackUrl matches only local targets; absence names this stack rather than acting as a wildcard.

Reference-creation gating now applies only to a relationship naming a Record in this stack. The other arms are ungated, and the rationale is written into the spec rather than left implicit: the gate exists so a reference cannot convey access to, or confirm the existence of, a Record the requester may not read. None of the other arms names a Record here, core never resolves them, so no access flows through one and an accepted write reports back only what the requester supplied. Gating a target in another stack would require dereferencing that stack at write time — and a stack cannot even recognize its own URL, since _config holds no stackUrl.

What is deliberately not here

Everything else #15 proposes describes a copy that left the stack, not the record that stayed. cid, previousCid and tombstone are properties of a syndicated copy; lexiconId is one row of one bridge's translation table, and a single field can hold exactly one mapping. All of it belongs to adapter-atproto — see the comment on #15.

externalIds on EntityContent is not here either, and does not need to be: as an alias relationship with an external target it is queryable through this same filter, where a content array would be opaque to the query engine and force a scan of every _entity record. alias and syndicated-to are documented as cross-type conventions in docs/commons/README.md.

No externalRelationshipQuery capability flag (proposed in #16's follow-up comment). Every adapter stores relationships in one table and the external case is the same lookup — a boolean that is true everywhere is dead weight apps branch on forever. AGENTS.md already says optional capabilities are optional methods, never booleans. Pinned by conformance fixtures instead; there were none for relatedTo at all.

No match-any relationship filter, either (62cc52d). An earlier draft let relatedTo be {}, meaning "carries any relationship at all" — which was the only reason the wire needed a boolean ?related=true switch, since a filter with every part optional can encode to no query parameters and arrive as an unfiltered query. Nothing else in RecordFilter has a match-any form (tags requires values, hasAttachment a label), so it was the odd exception rather than a missing convenience. Requiring a label, a target, or both removes the case and the switch together, and the type now guarantees the encoding can never widen a query. A @ts-expect-error test pins the refusal.

Unrelated work, folded in at the maintainer's request

Two things ride along that are not about the target union. Both were asked for explicitly; flagging them so a reviewer knows they were decisions rather than scope creep.

Prose that narrates a change (92c2f93). AGENTS.md and CONTRIBUTING.md both state the no-references-to-previous-implementations rule for code comments and stop there — so prose that dates itself passes review unremarked. This PR's own first draft did exactly that ("the reference fabric … is in core now", fixed in 79dc55d), which is how it surfaced. The rule now extends to spec and commons prose: AGENTS.md gets the checkable line, CONTRIBUTING.md the reasoning and a worked example, plus the note that issue numbers in prose are usually the same mistake wearing a different hat — a document that sequences itself against open issues stops being true the moment one closes. No changeset: contributor docs, not shipped package content.

Association filters read through their indexes (76a50af, d69e53b). Covered below, and carrying its own changeset so it lands in the changelog on its own terms rather than inside the target-union entry.

Spec

Observable behavior changes; all four sections updated in this PR:

  • docs/spec/data-model.md § Associations — the union, plus a new § Relationship targets on what each arm means and how association identity works
  • docs/spec/data-model.md § Filter — relatedTo as a label-or-target pattern, the stackUrl-is-not-a-wildcard rule, and why match-any is refused
  • docs/spec/identity.md § Group — the roster example, which showed a DID in recordId
  • docs/spec/access-control.md § Reference-creation gating — which arm is gated and why the others are not
  • docs/spec/wire-format.md § Query parameters, § Associations — scope-tagged params and the association response shape

Also README.md § Associations, and the commons docs (README, contact, article, message, place, text-types) — every #15/#16 reference across docs/ is gone, rewritten to describe the mechanism rather than the issue that proposed it.

Verification

All five, clean:

pnpm run format:check   ✓
pnpm run lint           ✓
pnpm test               ✓  1,348 passed
pnpm run build          ✓
pnpm run typecheck      ✓

28 new tests. The invariants they pin, rather than the count:

  • every target arm round-trips through SQLite storage
  • targets differing only by namespace are two associations, not a silent no-op — dissociate removes only the one it names
  • a record target does not match an entity target holding the same string (both in storage and in groupRoleFromAssociations)
  • a record-scoped roster entry confers no membership
  • a target in another stack, an entity target, and an external target are each ungated
  • label-only, namespace-wide and entity-targeted filters match; a local record target does not match the same id in another stack
  • a filter naming neither a label nor a target does not compile

The filter tests run twice over — against MemoryAdapter and against the real SQL predicate in record-adapter-sqlite — since the two are meant to agree and previously nothing checked that for relatedTo.

Notes for reviewers

Association filters are semi-joins, not correlated EXISTS. This began as a question about whether relatedTo was too broad a filter to allow, and measurement moved the answer somewhere else: breadth is not what costs, the SQL shape is. A correlated EXISTS makes the planner scan every record and probe the association primary key for each; r.id IN (SELECT …) drives from the association side and looks up only the matches. Measured on 20k records with 4k associations, the full-scan count drops from one to zero for all four filters:

Filter EXISTS semi-join
attachmentFileId 8.8ms 0.14ms resolves as a multi-index OR
tags × 2, intersecting to none 4.4ms 1.6ms
tag, 1k matches 5.0ms 3.9ms
hasAttachment, 2k matches 6.5ms 4.5ms

The gain tracks selectivity, because what is removed is the part proportional to stack size: a filter matching most of the stack gains little, a selective one gains a great deal. attachmentFileId benefits most since its condition spans two tables — SQLite unions two index lookups where the EXISTS form scanned once and probed both sides per row.

This is also what makes idx_assoc_related load-bearing. Under the EXISTS phrasing the planner never chose it: the correlated subquery always enters through record_id, so it used the primary key and stopped at kind, which left a filter naming a specific target reading fewer index columns than one naming only a label. f.search in the same builder already used the semi-join shape, so this brings the association filters in line rather than introducing a pattern.

Results are identical throughout — tags still means ALL-of via one intersected subquery per tag, and attachmentFileId still matches an attachment association or a file-ref content field.

The association table gains three columns, all in the primary key — so two copies of one record on two networks are two associations rather than a silent no-op. Stack files created before this won't have the columns.

Naming. scope is #16's word, kept, though the values now name identifier spaces rather than inside/outside. space or ref would be more literal. Cheap to change now, the same migration again later.

Follow-up worth filing: an adapter-atproto issue to inherit cid, tombstones and the type mapping, once #15 is rewritten down to bridge work.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XasADdjuVnw8cW5Eoqym81

A relationship's target now names which identifier space its value belongs
to, rather than assuming every reference is a Record id in this stack:

  { scope: 'record',   recordId, stackUrl? }
  { scope: 'entity',   entityId }
  { scope: 'external', ns, id }

The external arm is what lets a record point at an ATProto post, an
ActivityPub actor, an email address or a plain URL. Core expresses the
reference and never dereferences it, so no protocol is privileged and a
stack with no bridge installed carries no field that mentions publishing.

The entity arm closes a gap in the identity model rather than only serving
bridges. Group rosters stored member DIDs in a field typed RecordId, and
groupRoleFromAssociations() compared the two as plain strings — both are
string, so nothing caught it, in code that decides group-based reads. A
roster entry carrying a record target now confers nothing, even when its
value equals a member's DID.

RecordFilter.relatedTo moves with it, or the union would be half-writable:
you could store a target the query engine cannot address. Every part of it
is an optional pattern — {} matches any record carrying a relationship, a
bare label matches every target under it, and an external target with no id
matches a whole namespace, which is the inventory query a syndication tool
runs. Label-only and namespace-wide queries were not expressible before. A
record target with no stackUrl matches only local targets; absence names
this stack rather than acting as a wildcard.

Reference-creation gating now applies only to a relationship naming a
Record in this stack. The gate exists so a reference cannot convey access
to, or confirm the existence of, a Record the requester may not read, and
the other arms name nothing core resolves — gating a target in another
stack would additionally require dereferencing that stack at write time,
which core does not do, and a stack cannot even recognize its own URL.

Storage gains related_scope, related_ns and related_stack, all in the
association primary key, so two copies of one record on two networks are
two associations rather than a silent no-op, plus an index on the target
(there was none on related_id). Existing stack files predate those columns
and must be recreated. Over the wire the scope is implied by which
parameters appear, with related=true as the switch so a filter with no
qualifiers reaches the server instead of silently widening the query.

No capability flag for external relationship queries: every adapter stores
relationships in one table and the external case is the same lookup, so a
boolean that is true everywhere would be dead weight apps branch on
forever. Conformance fixtures pin the behavior instead — there were none
for relatedTo at all.

Refs #16, #15.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XasADdjuVnw8cW5Eoqym81
@changeset-bot

changeset-bot Bot commented Aug 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 535cc83

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
@haverstack/record-adapter-sqlite Minor
@haverstack/core Minor
@haverstack/conformance-fixtures Minor
@haverstack/adapter-api Minor
@haverstack/adapter-local Patch
@haverstack/blob-adapter-disk Patch
@haverstack/commons Patch
@haverstack/sqlite-shared Patch
@haverstack/wire-types Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

claude added 8 commits August 29, 2026 22:16
The text-types passage on the broadcast contract described the reference
fabric as having arrived rather than as being there, which dates the
document and reads as a changelog entry inside a design guide. Say which
layer carries what instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XasADdjuVnw8cW5Eoqym81
… prose

Both files state the rule for code comments and stop there, so prose that
narrates a change — "is in core now", "when #15 lands" — passes review
unremarked. It is the same rule and it matters more in the spec, which is
read by people deciding what to build against.

AGENTS.md gets the checkable line; CONTRIBUTING.md carries the reasoning
and a worked example, alongside the note that issue numbers in prose are
usually the same mistake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XasADdjuVnw8cW5Eoqym81
…switch

`relatedTo` accepted an empty object meaning "carries any relationship at
all". Nothing else in RecordFilter has a match-any form — `tags` requires
values, `hasAttachment` a label — so it was the odd exception rather than a
missing convenience, and it was the only reason the wire needed a boolean
`related=true` switch: with every part optional, the filter could encode to
no query parameters and reach a server as an unfiltered query.

Requiring a label, a target, or both removes the case and the switch with
it. The scope is still implied by which parameter appears, and the type now
guarantees one is always present, so the encoding cannot silently widen a
query — which is what the switch was defending. A @ts-expect-error test
pins the refusal, since typecheck fails if the empty filter ever compiles
again.

The target pattern gets a name of its own, RelationshipTargetPattern, now
that the filter is a union and would otherwise spell it twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XasADdjuVnw8cW5Eoqym81
The changeset still described the match-any filter and the wire switch that
served it, both of which the previous commit removed. Changeset prose lands
in the published CHANGELOG verbatim, so a stale sentence there documents a
feature consumers will not find.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XasADdjuVnw8cW5Eoqym81
The relationship filter was a correlated EXISTS, which makes the planner
scan every record and probe the associations primary key for each. Phrased
as a semi-join it drives from the associations side instead, reading the
matching rows through idx_assoc_related — or idx_assoc_kind_label for a
bare label — and looking up only those records. Work becomes proportional
to the matches rather than to the size of the stack.

Measured on 20k records with 2k relationship associations: 4.3ms to 1.4ms
for a record target, 4.9ms to 1.2ms for a bare label, and the gap widens
with record count since only the EXISTS form grows with it.

This is also what makes idx_assoc_related load-bearing. Under the previous
phrasing the planner never chose it: the correlated subquery always enters
through record_id, so it used the primary key and stopped at `kind`, which
left a filter naming a specific target reading *fewer* index columns than
one naming only a label.

`f.search` in this same builder already uses the semi-join shape, so this
brings the relationship filter in line with it rather than introducing a
pattern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XasADdjuVnw8cW5Eoqym81
The two were bundled into one clause, which read as though the index were
what makes two copies on two networks distinct associations. The primary
key does that; the index is what keeps a relationship query proportional to
its matches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XasADdjuVnw8cW5Eoqym81
…index

Extends the relationship-filter change to the other three. `tags`,
`hasAttachment` and `attachmentFileId` were correlated EXISTS subqueries,
so each made the planner scan every record and probe the association
primary key for it. As semi-joins the planner drives from the association
side, reading matching rows through idx_assoc_kind_label,
idx_assoc_kind_file_id or idx_file_refs_file_id and looking up only those
records.

Measured on 20k records with 4k associations, no filter now needs a full
table scan — the count drops from one to zero in all four cases. The gain
tracks selectivity, since what is saved is the part proportional to stack
size: two tags intersecting to nothing goes 4.4ms to 1.6ms, while a single
tag matching 1k records goes 5.0ms to 3.9ms, where materializing the result
dominates either way.

attachmentFileId gains most, 8.8ms to 0.14ms, because its condition is an
OR over two tables: SQLite resolves the semi-join form as a multi-index OR
across idx_assoc_kind_file_id and idx_file_refs_file_id, where the EXISTS
form scanned the records once and probed both sides per row.

Results are identical — the `tags` ALL-of semantics still come from
intersecting one subquery per tag, and attachmentFileId still matches an
attachment association or a file-ref content field.

The query-shape work moves to a changeset of its own rather than riding
inside the target-union entry, since it is unrelated to that change and
belongs in the changelog on its own terms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XasADdjuVnw8cW5Eoqym81
A discriminated union is a compile-time promise, and the callers that
matter have made no such promise: a server maps a request body onto an
association and query parameters onto a filter, both plain objects the
type never saw. Three of the union's guarantees were resting on it alone.

Reference-creation gating tested `stackUrl` for presence while everything
else — the association primary key, targetEqual(), and both query
predicates — reads absent and empty as one target, this stack. So
`stackUrl: ''` skipped canReadReferent() and was then stored, read back
and matched as a plain local record target, indistinguishable on disk
from the gated spelling. The gate now tests for a value, so both
spellings of a local Record require read access to it.

An unrecognized `scope` reached the same place from the other side: the
gate let it past as "not a record target", and the storage mapper's arm
order settles anything it doesn't recognize as `record`. Targets are now
validated in the invariant layer, where assertValidSort() already lives
and for the same reason — an adapter cannot forget a rule core enforces.
A scope outside the three is refused, as is an empty string anywhere a
target names something: absence carries meaning on `stackUrl` and an
external `id`, so this stack and a whole namespace each have exactly one
spelling.

`relatedTo` likewise promised a label or a target and was checked for it
only by the type; a filter arriving empty built a bare `kind =
'relationship'` semi-join, which is the match-any form the union was
narrowed to remove. Stack.query(), ScopedStack.query() and APIAdapter
now refuse it — the last before a request the server would only reject.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0144EXoisPm29qZ7EU5kMm6b
@cuibonobo
cuibonobo merged commit 847f16b into main Aug 30, 2026
5 checks passed
@cuibonobo
cuibonobo deleted the claude/design-issues-15-16-plan-rwnori branch August 30, 2026 18:26
@github-actions github-actions Bot mentioned this pull request Aug 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants