Skip to content

Read a card type's saved search through its operations - #6201

Merged
habdelra merged 7 commits into
mainfrom
cs-12801-card-ops-query-operations-return-the-search-entries-resource
Sep 21, 2026
Merged

habdelra merged 7 commits into
mainfrom
cs-12801-card-ops-query-operations-return-the-search-entries-resource

Conversation

@habdelra

@habdelra habdelra commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Background

A card type can declare operations — named actions like addComment or
escalate that the realm carries out against a card without loading any card
JavaScript. An author writes them as statics next to the card's fields, and a
caller invokes them through operations(card).addComment({ body }), which
builds a request, sends it to the realm's operations endpoint, and answers with
what the realm wrote.

One of the base behaviors an operation can build on is query, and it is
the odd one out: a query declaration is not an action against one card, it is
a saved search over a collection —

@operation static openReports = {
  base: 'query',
  query: {
    filter: { on: () => Report, eq: { status: 'open', postedBy: actor() } },
    sort: [{ on: () => Report, by: 'headline', direction: 'asc' }],
  },
};

Searching is something this platform already does, through a single engine: a
query goes to the realm server's /_federated-search endpoint, comes back as a
stream of entries (each row is either prerendered HTML or a live
serialization, and a consumer renders it without caring which), and on the host
it is held by getSearchEntriesResource — a reactive resource that subscribes
to every realm it covers and re-runs itself when those realms report new index
data. Operations do not re-own any of that. So a declared query is declared
like every other operation and carried out nowhere near them.

Until now the operations bucket simply left queries out: operations(Report)
carried the creates and nothing else, and a declared query had no member at all.
This PR gives it one.

What a caller gets

operations(Report).openReports is now a member with two ways in.

Called, it answers the live entries resource — the same
{ entries, isLoading, meta, errors } every other search on the host answers
with:

let reports = operations(Report).openReports();

.query() answers the wire query that same invocation resolves to — or no
query at all when the session cannot say who the caller is, which the search
component reads as an idle search (see When there is no viewer) — for a card
that would rather render the rows itself through the search component the host
provides on @context:

get openReports() { return operations(Report).openReports.query(); }
<@context.searchResultsComponent @query={{this.openReports}} as |results|>

A payload fills the markers the declaration names — params('status') takes its
value from operations(Report).byStatus({ status: 'closed' }) — and actor()
resolves to the signed-in user, so a saved search can be written "mine" and mean
it.

How the declaration becomes a search

Two translations happen, and both already existed; this PR makes the first one
shared rather than re-implemented.

A declaration writes its query against JavaScript: on: () => Report names a
class, actor() and params('x') are markers standing where a value is only
known at invocation. Lowering turns that into an entry-wire query template —
classes become code refs, the card-rooted filter becomes the entry-addressed one
(statusitem.status) — with the markers left standing. The realm already
did this when it captured the type's definition for its cache. Resolving
then fills the markers in from the invocation and yields a concrete wire query.

The caller has the class in hand, so it lowers the declaration itself rather
than fetching the type's definition over the network — which is what lets
.query() be an ordinary synchronous call. The important part is that it runs
the same function the realm runs: the translation moved out of lowering.ts
(which reaches the BXL program canonicalizer, and so can never be imported by a
card module) into card-operations/query.ts (which reaches nothing but the
query grammar), and lowering.ts now calls it. A saved search therefore means
one thing wherever it is read, instead of two translations that have to be kept
in step by hand.

Which realms a search covers

A search that names no realms fans out across every realm the session can read,
which is never what a saved search meant to say. So the scope is resolved in
order, and a query that reaches the end with nothing is refused rather than sent:

  1. The declaration, if it named realms — a deliberate scope, which a call
    does not widen.
  2. The call: operations(Report).openReports(undefined, { realms: [...] }).
  3. The realm holding the type's own module, resolved through the host's realm
    service. This is the case a card is in when it invokes a saved search on a
    type it imports: the type and the cards live in the same realm, so "the
    type's realm" is the card's realm.

A query is scoped to a type, not to one card, so it is invoked on the class —
operations(Report), never operations(oneReport) — and for the same reason no
entry of a batch can name one: a batch is anchored on a card and commits writes
under one realm's lock.

When there is no viewer

A saved search can compare against the caller — eq: { postedBy: actor() } is
the point of a "mine" search — and that raises a question about where the search
runs. The prerender app authenticates as itself so it can render any card, and
the HTML it produces is served to everyone; a search that resolved the actor
there would put one identity's rows into a rendering every viewer reads. The
store already declines to resolve a card's query fields in that app for the
neighbouring reason: a render must be a pure function of the document it was
handed.

So the rule is one sentence: when the session cannot say who the caller is, a
search that compares against them answers no query at all.
The resource reads
that as an idle search and the search component renders no rows; the live render
that follows resolves against the viewer and fills them in. Three situations
land on it — nobody signed in, a sign-out that withdrew an actor the search had,
and a render. A saved search that does not read the caller is unaffected in all
three, and anything a declaration or a payload gets wrong is still raised at the
call rather than silently idling.

This is the one place the delivered shape departs from what was specified:
.query() answers SearchEntryWireQuery | undefined rather than always a
query. The search component already accepts an absent query, so a card hands the
result over unchanged — and the alternative was for a card's own getter to throw
during a prerender.

The session a query runs in

The host service that carries operations to a realm gains a second, optional
half of that bridge, for the things a saved search needs and a batch does not:
who the caller is (what actor() resolves to), which realm holds a module, and
the live resource itself. It is optional because it is the half an environment
can genuinely lack — in Node there is nothing to build a reactive resource with,
so a query there says so rather than half-working. Nothing about how a batch
reaches a realm changed.

Two refusals are worth calling out, both raised at the call rather than inside a
render: a payload that leaves a declared marker unfilled, and a query that
compares against the caller when nobody is signed in. The second is not a bad
payload — the search would run as written, compare stored values against nobody,
match nothing, and read as a saved search that genuinely found none.

Freshness, and calling it once

A query reads the search index, which lags a write until that write has been
indexed. A card you just wrote is read directly (read, or the store); a query
is for collections, and its resource refreshes itself as the realms it covers
index. Both the card-facing module and the client core say so where an author
will read them.

The other thing they say: make the call once. Every call builds an independent
search with its own realm subscriptions, exactly as the underlying resource
documents, so the returned resource belongs in a field or a one-time assignment
rather than in a getter. The query itself is read back through a thunk, so a
standing search follows what its payload resolves to rather than needing to be
rebuilt.

Where the changes live

  • packages/runtime-common/card-operations/query.ts — now holds both halves: the
    declaration → template translation (moved here from lowering.ts) and the
    template → wire query resolution.
  • packages/runtime-common/card-operations/lowering.ts — calls the shared
    translation instead of carrying its own copy.
  • packages/runtime-common/card-operations/client.ts — the query member, its
    scope resolution, and the search half of the transport interface.
  • packages/host/app/services/operations.ts — the host's implementation of that
    half: the session's user, the realm service, and getSearchEntriesResource.
  • packages/base/operations.ts — the card-facing typing: a declared query is
    callable with its declared payload and answers the resource, and .query()
    answers the wire query.

getSearchEntriesResource, the store's search, and the federated search
endpoint are untouched — this only produces wire queries and hands them over.

Testing

packages/realm-server/tests/card-operations-client-test.ts drives the client
core with a recording session: what .query() resolves to (markers filled, the
filter entry-addressed), that a call answers with what the session built and
reads the query back through the thunk, the three-step realm scope, and each
refusal. Every new assertion was run against a deliberately broken
implementation first, so each one is known to go red for its own reason.

packages/host/tests/integration/operations-query-test.gts runs the real thing
against an in-browser realm: a declared query answers the cards its filter
matches, actor() resolves to the signed-in user and excludes another user's
report, a payload selects a different set, the resolved wire query renders rows
through @context.searchResultsComponent, a search covers the type's realm until
the call names others, and a report written after the search started joins it
once the realm indexes it.

The existing operations invocation suite and the operation lowering unit suite
were re-run unchanged.

What the review changed, and what has been run since. Eight findings came
back (threads below); the two behaviour-level ones — the nested code ref and the
thunk — plus the no-viewer rule above are in the last three commits. The
client-core suite has been run against every one of them, each new case shown to
fail without the change it names, and lint and type-checks are clean in all four
packages. The two host integration files have not been re-run since those
commits: the machine's single test stack has been held by other work, and rather
than sit on the change I am letting CI be the first run of them. If CI disagrees
with anything here, that is the reason.


Stacked on #6198 (the operations() entry point), which it branches from; retarget to main before merging.

🤖 Generated with Claude Code

habdelra and others added 2 commits September 18, 2026 10:19
A `query` operation is declared next to a card type's other operations and
carried out by the search engine rather than by the operations endpoint: it
answers the live entries resource a search runs as, and `.query()` answers the
wire query behind it for a card that renders the rows itself.

The declaration is translated to an entry-wire query template by the same
function the realm uses when it captures the type's definition, so a saved
search means one thing wherever it is read, and the markers it leaves standing
are filled from the invocation — the caller's identity for `actor()`, the
payload for `params()`.

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

A batch is anchored on a card and a query runs against a type, so nothing a
batch registers can name a saved search; the extra rule that said the same
thing could not change an answer. The card-facing module now says what a
declared query is instead: index-fresh, invoked on the class, and answered with
a resource to keep rather than a result to await.

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

chatgpt-codex-connector Bot commented Sep 18, 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-18T15:10:50.488406Z 4f39b81 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.

@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

    1 files      1 suites   2h 43m 58s ⏱️
4 966 tests 4 952 ✅ 14 💤 0 ❌
4 981 runs  4 967 ✅ 14 💤 0 ❌

Results for commit 70e1ad2.

Realm Server Test Results

    1 files    248 suites   1h 21m 27s ⏱️
3 809 tests 3 809 ✅ 0 💤 0 ❌
3 860 runs  3 860 ✅ 0 💤 0 ❌

Results for commit 70e1ad2.

@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: 4f39b81266

ℹ️ 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/card-operations/client.ts Outdated

@habdelra habdelra 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 🤖]

Reviewed the delta against the branch this is stacked on, with the weight on three questions: whether the translation this consolidates can still mean two things on the two sides, what the returned resource's life actually is, and whether the new tests discriminate. I ran the client-core suite standalone (82 passing) and drove the member directly through a stub session to exercise the refusals, the scope rules and the thunk. I did not run the host integration suite, and I did not review the stacked base.

No blocking issues. Two of the eight asks change behavior rather than prose — the ownerless search's lifetime and the default realm scope for a nested code ref — and I would settle those before merge.

Recommendations, detail in the linked threads:

  1. Bound an ownerless search's life, or say the bound is the tab — thread on search in packages/host/app/services/operations.ts. The card path is the ownerless one, and it currently parents to the one object nothing destroys.
  2. Resolve the default scope through moduleFrom rather than a top-level .module cast — the bot's thread on queryScope. I confirmed it: an ancestorOf ref (what identifyCard hands back for a card class reached as a superclass rather than as its own export) yields undefined and the call is refused as unscoped, while moduleFrom resolves the same ref to its module. Replied there with the repro.
  3. Decide what the thunk is for — thread on queryMember. The eager resolve front-loads the first refusal rather than keeping one out of a render, and "varies with what the payload holds" needs a reactive payload to be true.
  4. Make the thunk test discriminate — thread on card-operations-client-test.ts. The assertion compares the thunk against itself.
  5. Settle what a declared realms: [] means — thread on resolveQuery. Two comments in this change give opposite answers and the refusal message describes the declaration wrongly.
  6. refusingSink renders the path twice, one segment short — thread on it, with a suggestion.
  7. Share the three predicates the consolidation left in two homes — thread on query.ts.
  8. Carry the host's two load-bearing sentences onto the card-facing row type — thread on SearchEntry.

One decision no thread covers: a saved search invoked inside the prerender app. routes/render.ts marks that app for its whole life, and StoreService#resolvesQueryFieldsEagerly declines to resolve a card's query fields there on the stated ground that those stores "must render as a pure function of the document they were handed". The write half of the operations transport has its own gate for that app; a saved search has none. Invoked from a card's template during an index render it issues a live federated search, and actor() resolves to whatever that app authenticates as rather than to a viewer — so an actor-scoped saved search bakes one identity's rows into HTML every viewer is then served. @context.searchResultsComponent already searches during a prerender, so the search itself may be the settled answer; the actor is the new part. Worth deciding explicitly rather than by omission.

Adjacent, not asked of this change: RealmService#realmOf answers with the key a realm was registered under, which may be a scoped identifier rather than a URL, so queryScope's two branches can put different spellings of "a realm" into the same realms array. normalizeRealms handles both today (it builds RealmPaths from the string rather than parsing a URL), so nothing is broken — it is just a spelling difference that only holds because every downstream reader happens to avoid new URL.

Comment on lines +87 to +97
// A search with no owner is tied to this service, which lives as long as the
// session: right for a search whose results the session keeps, and why a
// caller with a shorter life — a component, a controller — names itself as
// the owner and has its search torn down with it.
search: OperationsSearch = {
actor: () => this.matrixService.userId ?? undefined,
realmFor: (identifier: string) => this.realm.realmOf(rri(identifier)),
entries: (
getQuery: () => SearchEntryWireQuery,
opts?: { owner?: object },
): SearchEntries => getSearchEntriesResource(opts?.owner ?? this, getQuery),

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 🤖]

An ownerless search never ends, and "lives as long as the session" is the part that has to hold. getSearchEntriesResource states its own bound — "per-render calls pile up live instances on the parent until the parent is destroyed" — and parenting to a service removes it. MatrixService#logout clears auth, calls session.notifySessionEnded(), resets matrix and realm state and transitions the router; nothing in that path destroys a service. So every search any card ever started is still parented here after a logout and a re-login, still subscribed to its realms, and still re-running whenever an index event touches a type its query is anchored on. The real bound is the tab.

That matters more here than it would at a host-owned call site, because the ownerless path is the card path: card-api never calls setOwner, so a CardDef instance is not an Ember-owned destroyable and cannot be passed as owner — a card author only has one to name from inside a component class. myReports = operations(Report).myReports() as a field on the card gets the unbounded search by construction, which is exactly the shape the member's own docs recommend.

Two ways out: hold what this service parents and drop it when the session resets (RealmService already registers with session for this, and this service already registers a destructor for the global bridge); or keep the default and say plainly, in both this comment and SearchInvokeOptions#owner, that an ownerless search lives for the tab — so an author can price a per-card call.

Regression, non-blocking, but I'd settle it here: the cost is per call and permanent.

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 🤖]

Taken as written, and the wording is what changed rather than the parenting.

An ownerless search is now described for what it is: parented to a service, which is destroyed with the application instance and not by a sign-out, so its life is the tab. The comment says that, says which shape of call takes it (a card holding the resource in a field, since a card instance is not application-owned and cannot be named as an owner), and says what to reach for instead when the search should end with a view — hand the query to the search component rather than holding the resource.

The other half of the concern is smaller after the thunk change in the same commit: a signed-out session withdraws the actor and empties the realm map, and the thunk now answers no query at all in that case, so a tab-lived search parks rather than going on re-running against a session that ended. What it keeps is its realm subscriptions.

Bounding the parenting itself — dropping what this service holds when the session resets — is a design step I'd rather take deliberately than fold in here, since it decides what a live resource sees when its realms go away.

Fixed in 9a224be749 and fd79561993.

Comment on lines +572 to +605
// The resource reads its query back through a thunk, so the search this call
// stands for varies with what the payload holds rather than with how many
// times the call is made. That makes the call itself the thing to make once —
// a field, a one-time assignment, never inside a getter or during render —
// exactly as the underlying resource documents: every call builds an
// independent search with its own realm subscriptions.
//
// What it answers with is index freshness. A search reads the index, which
// lags a write until that write is indexed, so a card just written is read
// directly (`read`, or the store) rather than queried for; the resource
// refreshes itself as index events arrive.
function queryMember(
subject: OperationsSubject,
env: OperationsEnvironment,
name: string,
info: CarriedOperationInfo,
): QueryMember {
let wireQuery = (
payload?: Record<string, unknown>,
opts?: SearchInvokeOptions,
) => resolveQuery(subject, env, name, info, payload, opts);
let member = ((
payload?: Record<string, unknown>,
opts?: SearchInvokeOptions,
) => {
let search = searchBridge(env, name);
// Resolved once here, where the caller is: a payload the declaration
// cannot resolve is a mistake at the call, and left to the thunk it would
// first be raised inside a render instead.
wireQuery(payload, opts);
return search.entries(
() => wireQuery(payload, opts),
opts?.owner === undefined ? undefined : { owner: opts.owner },
);

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 🤖]

Two asks about the thunk.

The eager call front-loads the first refusal; it does not keep one out of a render. The thunk re-runs the whole of resolveQuery on every recompute of the resource's args, and modify runs "inside a tracked computation (a property access on the resource proxy, typically mid-render)" — the resource's own words. Two of the refusals are about the session rather than the payload, and I drove both through a stub session: with the actor withdrawn the thunk throws actor-required, and with realmFor answering undefined it throws names no realm to search. A recompute is reachable because search.actor() reads MatrixService#userIdthis.client → the @tracked _client, which resetState() reassigns — and that same reset has already emptied RealmService's realm map. So: is the eager call meant to be the only guard? getSearchEntriesResource takes SearchEntryWireQuery | undefined and goes idle on undefined, so the thunk could answer undefined for the two session conditions and keep the throw at the call, which is what this comment says it wants.

"varies with what the payload holds" holds only for a reactive payload. The thunk closes over the payload object and a re-read does follow a mutation of it — I checked. But a plain Record<string, unknown> entangles nothing, and neither do the other two reads: RealmService keeps _realms as a deliberately untracked Map, and the actor is the only tracked read in the whole thunk. So for the payload shape both of this member's examples show — byStatus({ status: 'closed' }) — the args never invalidate, the thunk runs once, and the standing search is a fixed search. Worth saying what a payload has to be for the affordance to exist, since an author reading this will otherwise hold one search and change the payload.

Regression, non-blocking.

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 🤖]

Both asks answered, and the first one changed behavior.

The thunk no longer throws for the two session conditions: it answers no query, which parks the resource. A signed-out session raises both of them at once, and a search that threw from inside a render would take the render with it rather than showing nothing — which is what a session that can no longer answer a query should show. A payload the declaration cannot resolve is still the caller's mistake, so that one is still raised at the call; the eager resolve is what does it, and the comment no longer claims more for it than that.

On the second: the doc now says what the affordance needs. One call stands for a search that moves only if the payload's own values are tracked; a payload of literals is a fixed search, which is the common case and the reason the call is the thing to make once.

The suite asks for both halves now — a re-read follows a mutation of the payload object and follows the session's actor, and a third case drives the parking through a sign-out and back.

Fixed in 9a224be749 and 00645d9ac3.

Comment on lines +650 to +659
if (!query.realms?.length) {
// A search with no realms fans out across every realm the session can
// read, which is never what a saved search meant to say: a declaration
// that named none left its scope to the caller, and a caller that named
// none left it to the type's own realm. Refused here rather than sent, so
// a scope nobody chose is not answered with results from everywhere.
throw new Error(
`operation "${name}" on ${subject.displayName} names no realm to search: the declaration names none, the call named none, and the realm holding the type could not be resolved — omitting them would search every realm this session can read`,
);
}

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 🤖]

A declaration that writes realms: [] is refused here by a message saying it named none, and lowerQueryOperation takes the opposite position one file away: "An authored realms is a deliberate scope and stands, empty included — realms: [] says 'these and no others', which is not the same as saying nothing." It duly leaves it alone, and this then rejects it as unscoped — confirmed against a declaration carrying realms: [], which throws with "the declaration names none".

Pick one. If the refusal is right, lowerQueryTemplate is where to say that a declared empty scope is not a scope, and the message here should name the case it actually hit rather than describe the declaration wrongly.

Regression, non-blocking.

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 🤖]

Settled toward the refusal, which is the behavior that was already there; the two comments now say the same thing.

What reads the query cannot tell "these and no others" from "any of them" — no realms on the wire means every realm to the search engine — so an empty list is not a scope a caller can honor, and widening it silently is the one outcome a saved search never meant. The resolution still leaves a declared list alone, and its comment now says why that is not the same as honoring an empty one. The refusal names the case it actually hit: a declaration that wrote the empty list is told so, rather than being described as having named none.

Covered by a second case in the scope test.

Fixed in 9a224be749.

Comment on lines +698 to +706
function refusingSink(subject: OperationsSubject, name: string) {
return {
add(_code: string, path: string, message: string): never {
throw new Error(
`operation "${name}" on ${subject.displayName} declares a query that cannot be run: ${path} ${message}`,
);
},
};
}

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 🤖]

The path lands twice, and one segment short. Every sink.add call already opens its message with the path, so an unresolvable class reads:

... declares a query that cannot be run: query.on `query.on` names a class that no module exports, so there is no code ref to store for it

And query.on is not where it is: lowerQueryTemplate calls resolveTypes on declaration.filter with the default path = 'query', so a filter's type reports query.on and a sort entry's reports query[0].on rather than query.filter.on / query.sort[0].on. The labels predate this change, but on the realm they only ever land on a stored definition's issue list; here they are what a card author reads at the call, so it is worth passing 'query.filter' and 'query.sort' as the starting paths while this is in hand.

Suggested change
function refusingSink(subject: OperationsSubject, name: string) {
return {
add(_code: string, path: string, message: string): never {
throw new Error(
`operation "${name}" on ${subject.displayName} declares a query that cannot be run: ${path} ${message}`,
);
},
};
}
function refusingSink(subject: OperationsSubject, name: string) {
return {
add(_code: string, _path: string, message: string): never {
throw new Error(
`operation "${name}" on ${subject.displayName} declares a query that cannot be run: ${message}`,
);
},
};
}

Doubling is this change; the labels are pre-existing and now user-facing. Non-blocking.

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 🤖]

Both taken, suggestion included.

The sink drops the path — every message already opens with it — and lowerQueryTemplate now hands resolveTypes the starting paths query.filter and query.sort, so a filter's type reports query.filter.on and a sort entry query.sort[0].on. The labels are what a caller reads at the call now, which is what makes the segment worth paying for.

Fixed in 9a224be749.

Comment on lines +162 to 172
export interface SearchEntry {
id: string;
realmUrl: string;
html: SearchEntryRendering[];
item?: CardResource<Saved> | FileMetaResource;
iconHtml?: string;
displayName?: string;
codeRef?: ResolvedCodeRef;
indexGeneration?: number;
htmlGeneration?: number;
}

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 🤖]

This is the row type a card author reads, and it drops the two things the host's copy says are needed to read one safely: an empty html means the row matched but no rendering satisfies the query's htmlQuery yet (the re-run fills it in), and item is the raw wire serialization — sparse when it carries meta.sparseFields — never a store instance. Without those, an author branches on html.length as "this row has no rendering" and treats entry.item.attributes as a whole card.

The host's entries implementation annotates its return as SearchEntries, so a member the resource renames or drops does redden — the shapes cannot silently diverge in that direction. What that check cannot see is the host gaining a member this copy never learns, or this prose going stale against it. Carrying those two sentences here is the cheap half.

Non-blocking.

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 🤖]

Carried over — both sentences are on the members they are about: an empty html means no rendering satisfies the query's html terms yet and the re-run fills it in, and item is the wire serialization, sparse when it says so, never a card the store is holding.

Fixed in 9a224be749.

Comment on lines +455 to +461
function isMarker(value: unknown): value is Record<string, unknown> {
return isPlainObject(value) && typeof value.$ref === 'string';
}

function isBxl(value: unknown): value is { $bxl: string } {
return isPlainObject(value) && typeof value.$bxl === 'string';
}

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 🤖]

The translation has one home now; three predicates around it still have two. isMarker and isBxl here are byte-identical to the pair still in lowering.ts, and the class→code-ref step each side feeds into lowerQueryTemplate is also a hand copy: identify in lowering.ts and identifyDef in packages/base/operations.ts differ only in whether identifyCard arrives on a context or is imported. A marker spelling that lands in one copy and not the other, or a thunk-unwrapping rule that does, puts the two sides back to meaning different things — which is the failure this move exists to close.

lowering.ts can import the two predicates from here; the dependency already runs that way. The identify pair needs a home both sides can reach — client.ts is what the barrel re-exports and what base/operations.ts imports, so exporting the helper here and re-exporting it there would give it one.

isMarker/isBxl duplication arrives with this change; identify/identifyDef predates it and this is what makes it load-bearing. Non-blocking follow-up.

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 🤖]

Both halves done, including the one you called a follow-up.

isMarker and isBxl are exported from query.ts and lowering.ts imports them, so the marker spellings have one definition. The identify pair is now one function too: codeRefForDef in code-ref.ts, which is where identifying a class already lives and which both sides can reach — lowering.ts passes the identifier its context carries, and base/operations.ts passes the imported one. That was the copy that mattered, since a thunk-unwrapping rule landing on one side only would put the two readings of a written class back to differing.

Fixed in 9a224be749.

Comment on lines +1065 to +1069
assert.deepEqual(
searched[0].getQuery(),
searched[0].query,
'the query is read back through the thunk, which is how the search follows what the invocation resolves to',
);

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 🤖]

This assertion cannot fail for the reason it states. The harness builds Searched with query: getQuery(), so this compares the thunk against itself — it holds for any deterministic thunk, including () => aFrozenConstant. Nothing anywhere in the suite pins that re-reading it follows anything, which leaves the design's central affordance unguarded.

The stub already owns both inputs, so the discriminator is cheap: read getQuery(), flip the harness's actor (or mutate the payload object the call was given), read it again, and assert the second differs. I ran both changes against the member directly and the re-read does follow them, so the test would pass — it just is not asked.

Test gap, non-blocking.

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 🤖]

You're right that it compared the thunk against itself, and the replacement asks for the affordance rather than restating the stub.

The harness's session is now a record the test can write to. The case mutates the payload object the call was handed and asserts the re-read follows it, then moves the session's actor and asserts the re-read follows that. A separate case drives the parking: actor withdrawn, realms withdrawn, then both restored, with the same search resuming.

Shown to discriminate — pinning the thunk to resolve once turns three of those assertions red.

Fixed in 9a224be749 and 00645d9ac3.

habdelra and others added 3 commits September 18, 2026 11:40
…ding each

The default scope reads the type's module through `moduleFrom`, so a class
named by a nested ref — what identifying a class reached as a superclass
answers with — covers its own realm instead of being refused as unscoped.

A standing search parks when the session stops being able to resolve its
query: signing out withdraws the actor and the realms, and a thunk that threw
there would take a render with it. A payload the declaration cannot resolve is
still raised at the call.

A declared empty realm list is refused for what it is rather than described as
naming none, the class-to-code-ref reading and the two marker predicates each
have one home, and the refusal a caller reads no longer says the path twice.

The suite asks the thunk to follow both things it resolves against, covers the
nested ref and the parked search, and each new case was shown to fail without
the change it names.

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

A render authenticates as itself so it can render any card, and what it
produces is served to everyone — so a search that compares against the caller
resolves nothing there, rather than putting one identity's rows into shared
HTML. The live render that follows fills them in.

The same answer covers the other two ways a session cannot say who the caller
is: nobody signed in, and a client that is not up yet. A saved search that does
not read the caller is unaffected in all three, and what a declaration or a
payload gets wrong is still raised at the call.

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

Copy link
Copy Markdown
Contributor Author

[Claude Code 🤖]

Answering the decision ask in the review body — the one no thread covered — with code rather than a note, because it is a hazard this change introduces rather than one it inherits.

A render has no viewer, so a saved search that compares against one now answers none. Before this change a card had no supported way to get the viewer's identity into a search; actor() is that way, and a saved search invoked while the prerender app renders a card would have resolved it to whatever that app authenticates as — putting one identity's rows into HTML that everyone is then served. The store's own rule for that app says the same thing from the other side: a render must be a pure function of the document it was handed.

So the host's search bridge reports no actor inside the prerender app, and "the session cannot say who the caller is" is now one rule with one answer: no query, which the resource reads as an idle search and the search component renders as no rows. The live render that follows resolves it against the viewer and fills the rows in. The same rule covers the two ordinary cases — nobody signed in, and a matrix client that is not up yet, which throws rather than answering. A saved search that does not read the caller is unaffected in all three.

One consequence worth seeing in the diff: .query() now answers SearchEntryWireQuery | undefined rather than always a query. That is a deviation from the shape the work was specified with, taken deliberately — the search component already accepts an absent query as idle, so a card hands the result over unchanged, and the alternative was for a card's own getter to throw during a prerender.

Not taken here: bounding what the operations service parents. An ownerless search is now described as living for the tab, and the parking above means a signed-out one stops resolving rather than going on re-running, but dropping the searches a service holds when a session resets decides what a live resource sees when its realms disappear, and that is a design step I would rather take deliberately than fold into this.

On the adjacent note about realm spellings: agreed it is not broken and not this change's to fix. realmOf answers with the key a realm was registered under and realmHref normalizes what a caller passes, so the two branches can put different spellings into one realms array; every reader downstream builds RealmPaths from the string rather than parsing a URL, which is why it holds. Worth a follow-up that makes one spelling authoritative rather than leaving it resting on that.

@habdelra
habdelra requested a review from a team September 18, 2026 19:19
@habdelra
habdelra changed the base branch from cs-12799-card-ops-operations-entry-point-host-operations-service to main September 21, 2026 13:22
habdelra and others added 2 commits September 21, 2026 09:35
…rites

Both sides added to the same three places. The operations service now holds
the matrix service a saved search reads its caller from and the card service a
write reports authorship through; the client core carries the query member
beside the batch's local ids; and the client suite's harness describes both a
session that runs searches and one that hands back resources.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@habdelra
habdelra merged commit 0fdb1bf into main Sep 21, 2026
75 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.

2 participants