Realm resource URLs are always linksTo, never StringField/UrlField - #121
Conversation
…ields (CS-11070) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Staging Workspace Sync SuccessfulSuccessfully synced changes to staging workspace Sync Details |
habdelra
left a comment
There was a problem hiding this comment.
Code review of #121 (docs-only). The rule itself is right and well-motivated — the string-URL-to-a-card shape really is a silent-rot bug worth a cardinal rule. Five findings, all about scope and propagation rather than the substance:
skills/boxel/SKILL.md:33— the unqualifiedNEVERcontradicts required practice forFileDef's ownid/url/sourceUrlstring fields, which the repo elsewhere instructs setting to a realm file URL. Needs a carve-out (and in the three places that repeat the wording).skills/boxel/SKILL.md:45— theSkill/tree's twin decision tree (Skill/dev-core-concept.md:41-44) wasn't updated; perindex.md's maintainer note the two harnesses now drift on exactly the field-selection guidance this PR is about.skills/boxel-workspace-cardinal-rules/SKILL.md:160— the new local## 12.collides withboxel/SKILL.mdCardinal Rule 12, which is the near-inverse rule; the file's own preamble relied on 12 being past the end of the list.skills/boxel/references/base-field-catalog.md:88— no carve-out for host-mode routed paths, whichpatterns/build-site-config-with-theme(source-proven) stores in aUrlFieldaspageUrl.skills/boxel/references/base-field-catalog.md:15— the canonical detail for a general rule sits inside the image-pair-pattern section, soindex.md's unanchored "Details" link is hard to land on for a non-image pointer field.
None are blocking; (1) and (2) are the ones I'd fix before merge.
Generated by Claude Code
| | 13 | **🚨 `linksToMany` JSON shape uses INDEXED KEYS, never an array under `links.self`.** Each linked item in a `linksToMany` field gets its own top-level relationship key with an indexed suffix. Correct: `"activityFeed.0": { "links": { "self": "..." } }`, `"activityFeed.1": { "links": { "self": "..." } }`. WRONG (and the host rejects with "instance ... is not a card resource document"): `"activityFeed": { "links": { "self": ["...", "..."] } }`. The array-inside-`self` shape is intuitive but not valid Boxel JSON:API — `links.self` is a single string per JSON:API spec, and Boxel's encoding of "many" is indexed top-level keys. See `references/core-patterns.md` "JSON:API instance shapes". | | ||
| | 14 | **🚨 `linksTo` fields never appear in `attributes` — not even as `null`.** A `linksTo` field is serialized under `relationships`, keyed by its field path — for a linksTo nested inside a contained field, a dotted key: `"cardInfo.theme": { "links": { "self": "../Theme/foo" } }`. An empty link is `{ "links": { "self": null } }`, or omit the key entirely. Writing `"cardInfo": { "theme": null }` (or any value for the link) into `attributes` passes lint and writes successfully — then every read of the instance throws `linkTo field 'theme' cannot deserialize non-relationship value null` until the raw JSON is repaired by hand. | | ||
| | 15 | **🚨 Any function a template *calls* must be an arrow-function property, never a class method.** Glimmer invokes template-called functions (`{{if (this.isActive note) ...}}`, `{{fn this.method}}`) without binding `this`; a class body is always strict mode, so `this` is `undefined` and the first property access throws **during render**, which poisons Ember's renderer — the whole application freezes until reload. Lint passes, and `{{on}}` handlers mask the same mistake (there it merely breaks one handler). Write `isActive = (note: string) => this.activeNotes.has(note);`, never `isActive(note: string) { ... }`. Getters are safe (the template reads them off `this`); `@action` methods also bind, but arrow properties are the convention. See `boxel-workspace-cardinal-rules/SKILL.md` #11. | | ||
| | 16 | **A URL that points at a Boxel realm resource is a link, never a string.** If a field's value is the URL of a card instance or a realm file — an absolute realm URL (`https://<realm>/Person/jane`), a relative path (`../Theme/foo`), or any URL a realm serves — model it as `linksTo` / `linksToMany` (a `FileDef` subtype for files), **NEVER** as `contains(StringField)` or `contains(UrlField)`. A string-typed realm URL is invisible to the index: no invalidation when the target changes, no broken-link detection, no `<@fields.X />` rendering of the target, no query traversal — the reference silently goes stale when the target moves or is deleted. This is the exact complement of Rule 12: external URLs never go in relationships, and realm-resource URLs never go in string attributes. `UrlField` is for external URLs only. | |
There was a problem hiding this comment.
Medium — the unqualified NEVER contradicts required practice for FileDef's own descriptor fields.
FileDef (and every subtype) exposes id, url, and sourceUrl as string fields whose value is a realm file URL — see skills/boxel-file-def/references/available-fields.md:7-9. And the repo explicitly instructs setting them to the realm file identifier:
// skills/boxel-file-def/references/using-filedef-in-cards.md:63-69
card.outputImage = new ImageDef({
id: writeResult.fileIdentifier,
sourceUrl: writeResult.fileIdentifier,
url: writeResult.fileIdentifier,
...
});The same instruction appears in skills/boxel/references/imagedef.md, patterns/integrate-filedef-generated-image/README.md:16, patterns/integrate-thumbnail-card-ai/README.md:48, and patterns/integrate-screenshot-card-format/README.md:35,53.
Concrete misbehavior: a model asked to attach a generated image reads Rule 16 ("any URL a realm serves … NEVER as contains(StringField)"), then either refuses to populate sourceUrl/url on the fresh ImageDef or tries to convert them into relationships. The result is an ImageDef with no resolvable source, which renders nothing — and the model has no way to satisfy both documents.
Suggest a carve-out in the rule text, e.g. "…except a FileDef's own id/url/sourceUrl descriptor fields, which are strings by design." Same carve-out is needed in the index.md bullet, the glossary bullet, and workspace rule 12, which all repeat the unqualified wording.
Generated by Claude Code
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 25caa2b. Rule 16, the index.md bullet, the glossary bullet, the workspace rule, and the new catalog section all now carve out a FileDef subtype's own id/url/sourceUrl descriptor fields as strings by design, with the note that the relationship lives one level up (the card's linksTo pointing at the FileDef).
|
|
||
| ``` | ||
| Needs own identity / referenced from multiple places? → CardDef + linksTo | ||
| URL pointing at a card or realm file? → linksTo / linksToMany — never StringField/UrlField (Rule 16) |
There was a problem hiding this comment.
Medium — the Skill/ tree (the in-app assistant) does not get this line, so the two harnesses drift.
index.md's maintainer note: "Skill guidance lives in two hand-maintained trees that nothing syncs: skills/ … and Skill/ … A convention change must be authored into both, or the two harnesses drift."
This decision tree has a hand-maintained twin at Skill/dev-core-concept.md:41-44:
Needs own identity? → CardDef with linksTo
Referenced from multiple places? → CardDef with linksTo
Referencing a file (image, doc, etc.)? → FileDef subtype with linksTo
Just compound data? → FieldDef with contains
Skill/dev-core-concept.json points instructionsSource at ./dev-core-concept.md (a local copy), not at skills/, so nothing propagates. Skill/dev-quick-reference.md:62 (the UrlField import listing) and Skill/dev-file-def.md are the other two mirror sites.
Concrete misbehavior: the in-app assistant picking a field type consults Skill/dev-core-concept.md's tree, finds no rule about realm URLs, and emits @field author = contains(UrlField) — the exact bug this PR exists to prevent. Note the new workspace rule 12 does reach the assistant (Skill/boxel-workspace-cardinal-rules.json links straight at ../skills/boxel-workspace-cardinal-rules/SKILL.md), so this is a partial-coverage gap rather than a total one — but the field-selection guidance the assistant actually reads is the half that's missing.
Generated by Claude Code
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 25caa2b. The decision tree in Skill/dev-core-concept.md now carries the rule line, and the UrlField import in Skill/dev-quick-reference.md carries an external-URLs-only note. Skill/dev-file-def.md already teaches both sides (linksTo to reference a FileDef; descriptor fields as strings), so it needed no change.
| correctly and are safe in call position; arrow properties are the convention | ||
| in this repo. | ||
|
|
||
| ## 12. A URL pointing at a realm resource is never a StringField/UrlField |
There was a problem hiding this comment.
Low/Medium — local rule number 12 now collides with boxel/SKILL.md Cardinal Rule 12, which is the near-inverse rule.
This file's own preamble (lines 17-19) says a bare "Cardinal Rule N" citation in index.md/CLAUDE.md/AGENTS.md refers to the boxel skill's table, and that "several of those numbers are past the end of this list and do not name a rule here." Before this PR the list stopped at 11, so boxel Rule 12 (external URL in links.self bricks the realm) fell into that "past the end" safe zone.
Now:
| number | boxel/SKILL.md |
this file |
|---|---|---|
| 12 | external URL in relationships…links.self → bricks the realm |
realm URL in a string field → stale reference |
index.md:88 cites "Cardinal Rule 12" for the realm-bricking rule. A reader (or model) chasing that citation into this file — the file index.md links for rule details elsewhere — lands on §12, which is the opposite direction of the same cross-mixing mistake. That is precisely the confusion the PR is trying to eliminate.
The PR body notes the boxel table was appended to "so existing rule-number citations stay valid"; the same care is worth applying here. Options: number this section 2b/2-complement, fold it into §2 as a second half, or add an explicit "not the same as boxel Cardinal Rule 12" note in the heading line.
Generated by Claude Code
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 25caa2b. The addition is folded into that file's rule 2 as a second half ("The rule cuts both ways"), so the local list ends at 11 again and the preamble's past-the-end statement stays true. No local rule 12 exists anymore.
| - `relationships.<field>.links.self` is for card identifiers — relative paths (`"../Theme/foo"`) or absolute realm URLs only. | ||
| - External URLs (Unsplash, S3, CDN, any `https://` URL pointing at non-card content) go in `attributes.<field>URL` on the URL-twin field. | ||
| - Uploaded card-side images go in the linked ImageDef as a normal `linksTo` relationship. | ||
| - The rule cuts both ways: a URL that points at a **realm resource** (a card instance or a realm file) never goes in a `UrlField`/`StringField` attribute — always a `linksTo`/`linksToMany` relationship. A string-typed realm URL bypasses the index (no invalidation, no broken-link detection, no traversal) and rots silently when the target moves. |
There was a problem hiding this comment.
Low — no carve-out for host-mode routed paths / nav URLs, which an existing source-proven pattern stores in a UrlField.
skills/boxel-patterns/patterns/build-site-config-with-theme/README.md:19 (marked validated: source-proven):
export class PageConfig extends CardDef {
@field pageId = contains(StringField);
@field pageLabel = contains(StringField);
@field pageUrl = contains(UrlField); // ← nav target
...
}In that pattern the nav target is a page card in the realm, reached through the hostRoutingRules mechanism in patterns/link-host-mode-paths/README.md (/about, /pricing, …). Whether a routed host-mode path counts as "a URL a realm serves" is exactly the ambiguity the new rule's phrasing leaves open.
Concrete misbehavior: a model building a site config reads Rule 16 and rewrites pageUrl as linksTo(() => PageShell) — losing the curated public path, which is the whole point of hostRoutingRules — or it reads the pattern first and keeps the UrlField, and then a later review flags it as a Rule 16 violation. Either way the two documents give opposite answers for the same field.
Worth one clause distinguishing a realm resource identifier (→ linksTo) from a public/routed host-mode path (→ UrlField), and/or a note on the build-site-config-with-theme gotcha list.
Generated by Claude Code
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 25caa2b. Rule 16 and the new catalog section now distinguish a realm resource identifier (linksTo) from a curated public path routed via hostRoutingRules (UrlField), and the build-site-config-with-theme gotcha list opens with a note that pageUrl as UrlField is correct and must not be rewritten as linksTo.
| | `TextAreaField` | `'@cardstack/base/text-area'` | Multi-line plain text (sub-page). For paragraphs that aren't markdown. | | ||
| | `EmailField` | `'@cardstack/base/email'` | Validates as `user@domain`. Renders as `mailto:` link. | | ||
| | `UrlField` | `'@cardstack/base/url'` | Validates URL shape. Renders as `<a>` in non-edit modes. | | ||
| | `UrlField` | `'@cardstack/base/url'` | Validates URL shape. Renders as `<a>` in non-edit modes. **External URLs only** — a URL that points at a realm resource (a card instance or realm file) must be a `linksTo` / `linksToMany` field instead, never a string. | |
There was a problem hiding this comment.
Low — the "Details" target for a general rule is a bullet buried inside an image-specific section.
index.md:92 and the glossary bullet both send the reader here for the details of Rule 16, but the substantive paragraph landed at line 88, inside "Image fields — the URL/ImageDef pair pattern", under a bullet list headed "The contract:". The rule itself is not image-specific — the motivating example in the PR body is @field author.
Concrete misbehavior: a model deciding how to model author, owner, or relatedDoc follows the index.md link (which carries no anchor, unlike the Rule 12 bullet just above it which names its section), scans this file's headings, sees only an image-pair-pattern section, and concludes the guidance doesn't apply to a non-image pointer. The two decision-guide lines added at 215 and 236 are the only non-image touchpoints and they carry no rationale.
Suggest a short standalone subsection (sibling to "🔴 DateField vs DateTimeField — the schema-vs-value contract") with the general statement, and have the image-section bullet cross-reference it — then give index.md an anchored link.
Generated by Claude Code
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 25caa2b. The general statement now lives in its own subsection in base-field-catalog.md ("Realm-resource URLs — always a relationship, never a string", sibling to the DateField section) with the decay list and both carve-outs; the image-section contract bullet cross-references it, and the index.md and glossary bullets name the section in their pointers.
… review - Carve-outs for FileDef's own id/url/sourceUrl descriptor fields and hostRoutingRules nav paths (rule text, index, glossary, catalog, pattern) - Standalone "Realm-resource URLs" section in base-field-catalog.md; named pointers from index.md, glossary, and Rule 16 - Fold the workspace-rules addition into its rule 2 so local numbering no longer collides with boxel Cardinal Rule 12 - Mirror the rule into the Skill/ tree (dev-core-concept decision tree, dev-quick-reference UrlField import note) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Card schemas the AI generates sometimes store a pointer to another card as a URL string instead of a relationship:
Both versions pass lint, write, index, and render a clickable link. The string version then decays with no error: the index only tracks relationship links as dependencies, so nothing re-indexes the referrer when the target changes and no brokenLinks diagnostic fires when it is deleted; the template cannot render the target card (
<@fields.author />); queries cannot traverse it (author.name); and relationship links are relative paths that survive a realm copy, while the string holds an absolute URL that keeps pointing at the old realm.The skills so far only stated the opposite rule — an external URL in
relationships.<field>.links.selfbricks the realm — which nudged the model toward "a URL string is the safe choice". This states the boundary from both sides: external URL →UrlFieldattribute; realm resource (card instance or realm file) →linksTo/linksToMany, always.skills/boxel/SKILL.md(appended, so existing rule-number citations stay valid), plus a line in the data-shape decision tree.index.mdhighlights.boxel-workspace-cardinal-rules/SKILL.md, marked as the complement of its rule 2.base-field-catalog.md: note on the UrlField row, a pair-pattern contract bullet, and two decision-guide entries.attach-remote-imagepattern.