feat(core)!: discriminated target union on relationship associations - #207
Merged
Conversation
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 detectedLatest commit: 535cc83 The changes in this PR will be included in the next version bump. This PR includes changesets to release 9 packages
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 |
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
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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
_grouprecord stores a member's DID in a field typedRecordId—docs/spec/identity.mdsaid so outright — andgroupRoleFromAssociations()compared the two as plain strings. Both arestring, so nothing caught it, in code that decides group-based reads. Aninternal/externalunion would have left that in place and tagged itscope: 'internal', asserting the DID is a record in this stack. Theentityarm fixes it, and a roster entry carrying arecordtarget 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.relatedTomoves 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:{ label: 'series' }{ target: { scope: 'external', ns: 'atproto' } }{ target: { scope: 'entity', entityId } }{ label, target }The first two were not expressible before —
recordIdwas required. Arecordtarget with nostackUrlmatches 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
_configholds nostackUrl.What is deliberately not here
Everything else #15 proposes describes a copy that left the stack, not the record that stayed.
cid,previousCidandtombstoneare properties of a syndicated copy;lexiconIdis one row of one bridge's translation table, and a single field can hold exactly one mapping. All of it belongs toadapter-atproto— see the comment on #15.externalIdsonEntityContentis not here either, and does not need to be: as analiasrelationship 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_entityrecord.aliasandsyndicated-toare documented as cross-type conventions indocs/commons/README.md.No
externalRelationshipQuerycapability 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 istrueeverywhere is dead weight apps branch on forever.AGENTS.mdalready says optional capabilities are optional methods, never booleans. Pinned by conformance fixtures instead; there were none forrelatedToat all.No match-any relationship filter, either (
62cc52d). An earlier draft letrelatedTobe{}, meaning "carries any relationship at all" — which was the only reason the wire needed a boolean?related=trueswitch, since a filter with every part optional can encode to no query parameters and arrive as an unfiltered query. Nothing else inRecordFilterhas a match-any form (tagsrequires values,hasAttachmenta 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-errortest 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.mdandCONTRIBUTING.mdboth 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 in79dc55d), which is how it surfaced. The rule now extends to spec and commons prose:AGENTS.mdgets the checkable line,CONTRIBUTING.mdthe 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 worksdocs/spec/data-model.md§ Filter —relatedToas a label-or-target pattern, the stackUrl-is-not-a-wildcard rule, and why match-any is refuseddocs/spec/identity.md§ Group — the roster example, which showed a DID inrecordIddocs/spec/access-control.md§ Reference-creation gating — which arm is gated and why the others are notdocs/spec/wire-format.md§ Query parameters, § Associations — scope-tagged params and the association response shapeAlso
README.md§ Associations, and the commons docs (README,contact,article,message,place,text-types) — every#15/#16reference acrossdocs/is gone, rewritten to describe the mechanism rather than the issue that proposed it.Verification
All five, clean:
28 new tests. The invariants they pin, rather than the count:
dissociateremoves only the one it namesgroupRoleFromAssociations)The filter tests run twice over — against
MemoryAdapterand against the real SQL predicate inrecord-adapter-sqlite— since the two are meant to agree and previously nothing checked that forrelatedTo.Notes for reviewers
Association filters are semi-joins, not correlated
EXISTS. This began as a question about whetherrelatedTowas too broad a filter to allow, and measurement moved the answer somewhere else: breadth is not what costs, the SQL shape is. A correlatedEXISTSmakes 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:EXISTSattachmentFileIdtags× 2, intersecting to nonetag, 1k matcheshasAttachment, 2k matchesThe 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.
attachmentFileIdbenefits most since its condition spans two tables — SQLite unions two index lookups where theEXISTSform scanned once and probed both sides per row.This is also what makes
idx_assoc_relatedload-bearing. Under theEXISTSphrasing the planner never chose it: the correlated subquery always enters throughrecord_id, so it used the primary key and stopped atkind, which left a filter naming a specific target reading fewer index columns than one naming only a label.f.searchin 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 —
tagsstill means ALL-of via one intersected subquery per tag, andattachmentFileIdstill 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.
scopeis #16's word, kept, though the values now name identifier spaces rather than inside/outside.spaceorrefwould be more literal. Cheap to change now, the same migration again later.Follow-up worth filing: an
adapter-atprotoissue to inheritcid, tombstones and the type mapping, once #15 is rewritten down to bridge work.🤖 Generated with Claude Code
https://claude.ai/code/session_01XasADdjuVnw8cW5Eoqym81