Skip to content

Send only unsaved links in a new card's included, not resident saved ones - #6161

Merged
backspace merged 9 commits into
mainfrom
cs-13018-scope-included-to-lids
Sep 17, 2026
Merged

backspace merged 9 commits into
mainfrom
cs-13018-scope-included-to-lids

Conversation

@lukemelia

@lukemelia lukemelia commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Problem

When a new card is written, the serializer inlines into included[] every linked card the store has resident — saved or not. The realm keeps only the primary card plus any brand-new, unsaved links being created in the same request (identified by lid), and discards the already-saved ones. So every new-card save from a busy tab serialises, transmits, and then has the server throw away hundreds of KB of resident linked cards.

Fix

Push the realm's write-retention rule into the serializer, as an includedScope option on SerializeOpts:

  • 'all' — every resident link target rides along in included[]. The default, and what a direct serializeCard call gets.
  • 'local' — only the local (unsaved, lid-bearing) targets the card links to directly. The write shape: included is a co-creation manifest and saved targets are reference-only.
  • 'none' — no link targets at all.

An excluded target isn't serialized and its own graph isn't traversed, so the cost is skipped rather than filtered away afterwards: a new card linking into a large saved graph now serialises none of that graph on save. Its relationship entry is unchanged — links.self + id for a saved target, lid for a local one, which is exactly what the full walk's tail emits.

CardService#serializeCard maps the caller's intent onto a scope. The new-card save asks for withLocalResourcesIncluded and gets 'local'; every other caller gets 'none', which is the document they already received, since the service deleted included from it regardless.

The document sent on a new-card save now equals what the realm stores.

What a write co-creates

Under 'local', an unsaved card reachable only through a saved link is no longer co-created. Nothing the write persists could reference it: the saved link's own file isn't rewritten, and the write's response names only the primary card, so the co-created card's id would never reach the client — the realm would write an orphan file.

Relationship to #6160

#6160 (merged) fixed the "Card size exceeds maximum" failure this inflation caused, by measuring the size limit per-file. This change removes the wasted payload itself. They are separate because this one changes the serialised document shape, where #6160 only changed how the limit is measured.

Test

Integration | Store gains a case covering all three scopes: which targets reach included under each, that withLocalResourcesIncluded maps onto 'local', and — the assertion with the widest blast radius — that the primary resource is deep-equal under all three scopes. That equivalence is what makes 'none' safe as the default for the patch, copy, file-def and AI-tool callers, and it now fails loudly if either branch drifts.

Verification

  • ESLint and ember-tsc: clean locally
  • Host suite: in CI on this head, which is main merged forward rather than the base the earlier green run used

🤖 Generated with Claude Code

…d ones

When a new card is written, the serializer inlines into `included[]` every
linked card the store has resident — saved or not. The realm keeps only
the primary card plus any brand-new, unsaved links being created in the
same request (identified by `lid`) and discards the already-saved ones.

Filter the serialized `included` to the `lid`-bearing members in the one
code path that retains it (the new-card save). The document sent now
equals what the realm stores, which cuts wire bytes and serialisation
work on every new-card save from a busy tab, and keeps co-creation of
unsaved links intact.

The per-file size check added upstream already fixed the "card size
exceeds maximum" failure this inflation caused; this change removes the
wasted payload itself.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GsYGsuCqHJz9GAti4jheG

@lukemelia lukemelia left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Review focused on enumerating the consumers of withIncluded serialization and on pinning the new test to the filter; the base PR of this stack was reviewed separately.

Bottom line: no blocking issues. withIncluded: true has exactly one call site (persistAndUpdate in store.ts), which serializes both creates and PATCHes — and the realm's PATCH handler retains and co-creates lid members with the same machinery as POST, so the filter is semantics-preserving on both verbs. The new test fails with the filter hunk removed ("a resident saved link is not inlined") — it pins the change.

  1. Extract the lid-retention predicate to a single home — inline comment on serializeCard in card-service.ts. Non-blocking.

Contract note: after this change the write document reads coherently — included on a write is a co-creation manifest (lid-bearing resources), references stay in relationships, and the client sends exactly what the realm retains. The realm's silent discard of non-lid members becomes pure backward-compatibility tolerance; a follow-up could warn on (or eventually reject) them, so the next client-side drift fails loudly instead of re-inflating payloads unnoticed.

Comment thread packages/host/app/services/card-service.ts Outdated
@lukemelia
lukemelia added this pull request to stack #6163 September 16, 2026 21:54
lukemelia and others added 3 commits September 16, 2026 17:59
withIncluded read as a generic serializer toggle while delivering only
the lid-bearing subset. The new name uses the platform's own vocabulary
(lid = local id): what rides along in included is exactly the local
resources — instances that exist only in this tab and are co-created by
the write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The write path filtered included after api.serializeCard had already
walked and serialized every resident link target — the expensive part of
a save from a busy tab. SerializeOpts now carries includedScope ('all',
the default and prior behavior; 'local' — only unsaved, lid-bearing
targets, the write's co-creation manifest; 'none'), enforced where the
work happens: an excluded target contributes only its relationship
entry, and neither it nor its own linked graph is serialized. The
card-service wrapper maps withLocalResourcesIncluded onto 'local' and
its absence onto 'none', replacing the post-serialization filter.

One deliberate behavior shift: an unsaved card reachable only through a
saved link no longer rides along in this card's included — its
co-creation belongs to the dirty saved card's own save, not to a write
that merely references that card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-included-to-lids

# Conflicts:
#	packages/host/tests/integration/store-test.gts
@lukemelia
lukemelia marked this pull request as ready for review September 16, 2026 22:43
@lukemelia
lukemelia requested review from a team and habdelra September 16, 2026 22:43

@habdelra habdelra left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Reviewed the second commit only (the diff against its stacked base): the includedScope skip in LinksTo/LinksToMany, the CardService default flip, and the new tests. I traced the emitted relationship shapes and the realm's own create/patch retention loops against the new scopes; I did not run the host suite (CI is still queued).

No blocking issues. The retention rule the client now applies matches what the realm keepsserializeCardResource emits id and lid mutually exclusively, so the realm's typeof resource.lid !== 'string' skip discards exactly the saved members this change stops sending, and the skip branch's relationship output is identical to the full branch's for both the id and lid spellings.

Recommendations:

  1. Narrow CardService#serializeCard's parameter to Omit<SerializeOpts, 'includedScope'> — the spread silently discards a caller-supplied scope. See the thread on card-service.ts.
  2. Confirm the intent of no longer co-creating unsaved cards reachable only through a saved link, and say it in the includedScope doc comment. See the thread on card-serialization.ts.
  3. Collapse the now-threefold reference-relationship spelling (and the duplicated skip predicate) behind two helpers. See the thread on card-api.gts.
  4. Drop the first new test as subsumed, and pin the 'all' vs 'none' relationship equivalence instead — that equivalence is what makes the default flip safe for the patch / copy / file-def / AI-tool callers, and nothing asserts it. See the thread on store-test.gts.

The PR description still describes the earlier post-serialization filter and calls skipping the serializer walk "a possible follow-up in the base card API" — the merged shape of this branch does that skip. Worth refreshing before it becomes the merge commit message.

Adjacent, out of scope: the PATCH path's included.length === 0 && isEqual(primaryResource, original) short-circuit in realm.ts was previously near-unreachable from the host, because any resident saved link made included non-empty. With this change it starts firing on genuinely no-op saves, which is the behavior it was written for — worth knowing it is now live rather than dormant.


Generated by Claude Code

Comment thread packages/host/app/services/card-service.ts Outdated
Comment thread packages/base/card-api.gts Outdated
Comment thread packages/base/card-serialization.ts Outdated
Comment thread packages/host/tests/integration/store-test.gts Outdated
@github-actions

github-actions Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

    1 files  ±0      1 suites  ±0   2h 20m 28s ⏱️ -54s
4 859 tests  - 1  4 845 ✅  - 1  14 💤 ±0  0 ❌ ±0 
4 874 runs   - 1  4 860 ✅  - 1  14 💤 ±0  0 ❌ ±0 

Results for commit 0209738. ± Comparison against earlier commit 95da05c.

Realm Server Test Results

    1 files  ± 0    235 suites  ±0   1h 20m 19s ⏱️ - 4m 28s
3 395 tests +19  3 395 ✅ +19  0 💤 ±0  0 ❌ ±0 
3 444 runs  +21  3 444 ✅ +21  0 💤 ±0  0 ❌ ±0 

Results for commit 0209738. ± Comparison against earlier commit 95da05c.

Base automatically changed from cs-13018-card-size-exceeds-maximum to main September 17, 2026 07:53
backspace and others added 5 commits September 17, 2026 10:09
A link target contributes its relationship entry alone — with no resource in
`included[]` — when the walk has already serialized it or when the
includedScope excludes it. Both singular and plural linksTo wrote that entry
out longhand in each of those branches and again at the tail of a full
serialization, six sites that had to move together.

Collapse them onto two module-level helpers: one that answers whether the
scope excludes a target, one that builds the reference relationship.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`CardService#serializeCard` decides the scope from the caller's stated intent
and overwrites whatever the options carried, so a caller-supplied
`includedScope` was accepted by the type and then silently discarded. Narrow
the parameter to reject it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An excluded target is not traversed, so 'local' reaches the local targets a
card links to directly, not every local target in its graph: an unsaved card
hanging off a saved link is not co-created by the write. Say so, with the
reason it cannot be — nothing the write persists could reference it.

'all' is what a direct `serializeCard` call gets; describe it that way rather
than naming a copy path, which serializes through the card service and so
asks for no linked resources at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every caller of the card service's serialize but the write path now asks for
no linked resources, which is safe only because an excluded target emits the
same reference relationship a full walk's tail does. Assert that equivalence
directly — the primary resource is deep-equal under all three scopes — so a
later edit to one branch cannot drift from the other unnoticed.

Fold the card service's own case into the same test: the write path's stated
intent maps onto the 'local' scope, and nothing else about the document
changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@backspace

Copy link
Copy Markdown
Contributor

[Claude Code 🤖] Pushed 0209738ad0 addressing all four recommendations, one commit each, plus:

  • PR description rewritten — it described the post-serialization filter from the first commit rather than the merged includedScope shape.
  • main merged forward; the branch was 37 commits behind and its green run was on the older base.

On the adjacent note: agreed, and it survives the card-operations refactor — the no-op short-circuit is now executors.ts:746, and it does start firing on genuinely no-op saves, which is the behaviour it was written for.

@backspace
backspace merged commit ed76b2f into main Sep 17, 2026
72 of 73 checks passed
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.

3 participants