Skip to content

Store a scoped cross-realm link as sent, not as resolved - #6067

Open
backspace wants to merge 5 commits into
mainfrom
cs-12745-realm-file-serializer-rewrites-cross-realm-alias-links-to
Open

Store a scoped cross-realm link as sent, not as resolved#6067
backspace wants to merge 5 commits into
mainfrom
cs-12745-realm-file-serializer-rewrites-cross-realm-alias-links-to

Conversation

@backspace

Copy link
Copy Markdown
Contributor

Writing a card ran every links.self through the virtual network and relativized the result against the writing realm. A link into another realm cannot be relativized, so it was stored as that realm's resolved URL — a localhost host on a dev stack, the staging host on staging — even when the client sent the canonical @scope/name/... form. Files authored through the UI carried environment-specific URLs into realms that are version controlled.

The fix is to stop discarding the form

maybeRelativeReference already draws the distinction. It relativizes what it can, and otherwise returns the reference in the form it was given — its fallback comment says why: a prefix-form RRI is already canonical and portable.

The write path defeated that by resolving to a URL first, so the fallback had nothing but a URL left to return:

makeRelativeReference(
  virtualNetwork.resolveURL(selfLink, relativeTo),  // form already lost
  relativeTo,
  realmURL,
)

Passing a scoped reference through unresolved restores both cases at once — relativized when it points into the writing realm, alias preserved when it does not.

An earlier revision skipped relativization for scoped references outright. That was too broad, and four skills.spec tests caught it: the host canonicalizes link ids to a realm alias wherever a prefix mapping exists, so links to cards in the writing realm also arrive scoped, and storing those verbatim changed the form of same-realm links that had always been stored relative. Attached skills then counted zero, because the stored link no longer matched what the reader looked for.

One definition of "scoped", shared

isScopedReference moves to url.ts and both paths import it. Serving a document and storing one have to agree on which references are already canonical; the two holding separate notions of that is the bug rather than an incidental duplication, so a copy would be free to drift again. The read path's rule is unchanged — relativizeResource has always preserved scoped references verbatim.

Scope

Not included: folding an already-resolved URL back to its prefix on write, which the issue lists as optional. That rewrites links in files the request never meant to touch, so it is a migration decision rather than a side effect of a bug fix.

Tests

Two, in the card PATCH request / public writable realm module: a scoped cross-realm link stored verbatim, and a same-realm link still stored relative. Both are what this change decides.

The issue's third case — a link to a realm with no prefix mapping staying absolute — is not expressible against this harness. A non-scoped link is resolved and then fetched to validate it, so pointing one at a realm nothing serves returns 500 from the write itself rather than exercising what gets stored, and the harness serves a single realm. Nothing about that case is at risk here: the non-scoped branch runs exactly the code it ran before.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-10T13:02:14.912976Z 1d5b717 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1d5b71769a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/runtime-common/file-serializer.ts Outdated
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Host Test Results

    1 files  ± 0      1 suites  ±0   2h 42m 31s ⏱️ + 7m 35s
4 739 tests +66  4 725 ✅ +66  14 💤 ±0  0 ❌ ±0 
4 754 runs  +66  4 740 ✅ +66  14 💤 ±0  0 ❌ ±0 

Results for commit 0a9bea7. ± Comparison against earlier commit 0b6c093.

Realm Server Test Results

    1 files  ±  0    215 suites  +11   1h 19m 51s ⏱️ + 5m 54s
2 816 tests +155  2 816 ✅ +155  0 💤 ±0  0 ❌ ±0 
2 855 runs  +155  2 855 ✅ +155  0 💤 ±0  0 ❌ ±0 

Results for commit 0a9bea7. ± Comparison against earlier commit 0b6c093.

The write path resolved every `links.self` through the virtual network and
relativized it against the writing realm. A link into another realm cannot be
relativized, so it was stored as that realm's resolved URL — a localhost host
on a dev stack, the staging host on staging — even when the client sent the
canonical `@scope/name/...` form. Files authored through the UI therefore
carried environment-specific URLs into realms that are version controlled.

The read path already had the rule: `relativizeResource` preserves a scoped
reference verbatim, because such a reference is absolute and cross-realm by
construction whether or not this network knows its prefix.

`isScopedReference` moves to url.ts and both paths import it. Serving a
document and storing one have to agree on which references are already
canonical, and the two having separate notions of that is the bug rather than
an incidental duplication — a copy would be free to drift again.

Deliberately not included: folding an already-resolved URL back to its prefix
on write. That would rewrite links in files the request never meant to touch,
which is a migration decision rather than a side effect of fixing this.

Tests cover all three cases the stored form distinguishes: a scoped reference
kept verbatim, a same-realm link still relativized, and a link to a realm with
no prefix mapping still absolute.
The case cannot be expressed against this harness. A non-scoped link is
resolved and then fetched to validate it, so pointing one at a realm nothing
serves returns 500 from the write itself — `unexpected exception in realm
TypeError: fetch failed` — rather than exercising what gets stored. The harness
serves a single realm, so there is no second reachable one to link to.

Nothing about that behaviour is at risk here: the guard added for scoped
references leaves the non-scoped branch untouched, so a mapped or unmapped URL
runs exactly the code it ran before.

The two remaining tests cover what this change decides — a scoped reference
kept verbatim and a same-realm link still relativized — and both pass. That the
scoped case passes where the unmapped one 500s is itself the mechanism showing
through: a scoped reference is never resolved, so it is never fetched.
Skipping relativization for every scoped reference was too broad. The host
canonicalizes link ids to a realm alias wherever the realm has a prefix
mapping, so a link to a card in the *writing* realm also arrives scoped, and
storing those verbatim changed the form of same-realm links that had always
been stored relative. Four skills specs caught it: attached skills counted
zero, because the stored link no longer matched what the reader expected.

`maybeRelativeReference` already draws the distinction. It relativizes what it
can and otherwise returns the reference in the form it was given, and its
fallback comment says why — a prefix-form RRI is already canonical and portable.
The write path defeated that by resolving to a URL first, so the fallback had
nothing but a URL left to return.

Passing a scoped reference through unresolved restores both cases at once: it
relativizes into the writing realm as before, and a cross-realm link keeps the
alias the client sent.
Whether a link can be relativized is decided in URL space. `relativeTo` here
is always a URL, and `relativeReference` refuses a mixed RRI/URL pair rather
than resolve across forms — `sharedNamespace` returns undefined and the
fallback hands back the reference unchanged. Passing a scoped reference
straight in therefore looked like "cannot be relativized" even for a link into
the writing realm, so a same-realm link the host had canonicalized to a prefix
was stored as `@scope/name/x` where it had always been stored `./x`.

Resolving first asks the question properly: a link inside the realm is stored
relative whichever form it arrived in, and outside it a scoped reference is
stored exactly as sent while anything else is stored resolved.

The decision moves into `storedRelationshipLink`, exported so it can be tested
without a Definition and a DefinitionLookup. The same-realm scoped case cannot
be reached through the realm-server write suite — only `@cardstack/base/` is
prefix-mapped there and it is not the writable realm — so it is covered by unit
tests over a stub network, alongside the in-realm URL, cross-realm scoped,
cross-realm URL, and unresolvable cases.

Note for the record: four skills specs failed on the first dispatched run of
this branch and passed on the second, and the revision in between changed
nothing for the same-realm scoped case — both left such links unchanged. That
attribution was wrong; those failures were flakes, and the defect they were
blamed on survived until now.
Deciding realm membership by resolving first is wrong for a scoped reference
this process cannot resolve. `resolveURL` treats an unregistered prefix as a
relative reference and joins it against the base, so
`@cardstack/catalog/Pet/vangogh` came back as a URL inside the writing realm
and relativized to `./@cardstack/catalog/Pet/vangogh` — a path into a realm
that has no such file.

Such a reference is absolute and cross-realm by construction, so it now
short-circuits and is stored exactly as sent. Resolution is reserved for
references that can actually be resolved: a registered prefix, a URL, or a
relative path.

The unit tests missed this because the stub network registered every prefix it
was asked about, while the realm-server harness registers only
`@cardstack/base/` — the stub was more permissive than the thing it stood in
for. A case now covers the unregistered scoped reference the write suite
caught.
@backspace
backspace force-pushed the cs-12745-realm-file-serializer-rewrites-cross-realm-alias-links-to branch from 0b6c093 to 0a9bea7 Compare September 11, 2026 16:51
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.

1 participant