Summary
Expose the "default" workspace relationship as a toggleable interest badge on workspace entity cards, so a user can mark/unmark a workspace as their default for the current user-computer profile from the standard interest-toggle UI (the same affordance used for actionable, blocked, etc.). Today a "default workspace" is modeled by a default relationship (participants.applied-to = the user-computer profile, participants.value = the workspace) that is read at startup to auto-open the default workspace, but there is no UI to set or clear it — it can only be seeded as data.
Rather than special-casing default in the interest code, this enhancement generalises the interest mechanism to be data-driven: an interest-type definition declares which participant references the badged entity (target-participant) and which participant(s) scope it and to which session identity they bind (applies-to). These two attributes are required on every interest-type (there are no implicit defaults in code): the existing interests (actionable, blocked, assigned-to, not-interesting) are updated to declare their current {target, user} shape explicitly, and default registers with target-participant: "value" and an applies-to binding its applied-to participant to the current user-computer-profile. The same generic projector/toggle then serves every interest with no per-name branching.
Design note (per issue feedback): the new attributes are required, not optional/defaulted — each existing interest definition is edited to supply them. There is no singleton behavior: toggling default on simply creates a default relationship for the current profile, and toggling off deletes it; the feature does not auto-clear a profile's other defaults.
This is a feature/enhancement, not a defect.
Current State / Root Cause
The "default" relationship (how a default workspace is modeled today)
A default workspace is a relationship entity of type default. Its schema requires applied-to and value participants (NOT the generic target/user shape used by interests):
Phantom.Workspaces.Data.Core/JsonSchemas/default.json (participants block)
"participants": {
"properties": {
"applied-to": { "$ref": "core.json#/$defs/entity-id" },
"value": { "$ref": "core.json#/$defs/entity-id" }
},
"required": ["applied-to", "value"],
"additionalProperties": false
}
The relationship-type definition entity is default-entity-type.json (entity-id b1d4f3a2-8c5e-4b7d-9f6a-1e2d3c4b5a6f), currently typed only as entity/entity-type/json-schema/note — neither interest-type nor relationship-type:
Phantom.Workspaces.Data.Core/JsonEntities/schema-definitions/default-entity-type.json
"entity-types": [ "entity", "entity-type", "json-schema", "note" ],
"names": [
["json-schemas", "https://schemas.workspaces.phantom.to/workspaces/data/core/default.json"],
["entity-types", "default"]
]
The default workspace is read at startup and on closing the last workspace, keyed on the current user-computer profile:
Phantom.Workspaces/ViewModels/MainWindowViewModel.cs:3501-3557 (QueryDefaultWorkspaceIdsAsync)
var profileId = broker.EntityRepository.WorkspaceEntitySession.UserComputerProfileEntityId;
...
new EntityTypeQueryClause { EntityTypeNames = new EntityTypeNameSet(["default"]) },
new EntityFieldQueryClause
{
FieldPath = new FieldPath("participants", "applied-to"),
ComparisonOperator = FieldComparisonOperator.Equals,
Value = JsonSerializer.SerializeToElement(profileId.Value.ToString()),
}
// ... reads participants.value -> workspace EntityId
The read path returns every default whose applied-to == currentProfile (QueryDefaultWorkspaceIdsAsync collects a list of workspace ids). Callers at MainWindowViewModel.cs:3410 (initialize / open default) and :3483 (fall back to default when the last workspace closes). Nothing writes this relationship from the UI — it is data-only today.
Scope semantics. The default is per user-computer profile: the read path is keyed on applied-to == currentProfile. Any profile may independently mark a workspace as its default, and marking a default for one profile must not disturb another profile's default.
The "interest" mechanism (how toggleable badges work today)
An interest type is defined by an interest-type entity carrying applied/notApplied badge metadata and optional display-entity-types. Its schema is:
Phantom.Workspaces.Data.Core/JsonSchemas/interest-type.json — allOf relationship-type.json; required entity-types, applied, notApplied. There is no top-level unevaluatedProperties:false/additionalProperties:false (only the nested interest-state-content def sets unevaluatedProperties:false), so additional declared top-level properties are permitted.
InterestCatalog discovers interest types via a live query for entity-types = interest-type, and exposes their names centrally:
- Definition record:
Phantom.Workspaces/InterestCatalog.cs:17-25 — InterestTypeDefinition(Name, AppliedGlyph, NotAppliedGlyph, AppliedDescription, NotAppliedDescription, AppliedActionText, NotAppliedActionText, DisplayEntityTypes) (8 positional fields).
- JSON parsing:
InterestCatalog.cs:115-138 (TryReadInterestType)
- Live discovery:
InterestCatalog.cs:63-80 (CreateAsync, subscribes to interest-type entities)
- Central name set:
InterestCatalog.cs:57 (InterestTypeNames)
- Definition entities (each already carries
"interest-type" + "relationship-type"): actionable-entity-type.json, blocked-entity-type.json, assigned-to-entity-type.json, not-interesting-entity-type.json. Their relationship schemas (actionable.json, blocked.json, assigned-to.json, not-interesting.json) use participants { target, user } (plus optional view for actionable/blocked), required ["target", "user"], user constrained to x-entity-types: ["user"].
Badges are projected per entity by InterestBadgeProjector.Project (Phantom.Workspaces/InterestBadgeProjector.cs:18-39), which:
- decides applicability per entity type via
ShouldShowBadge using display-entity-types / display-interest-types (InterestBadgeProjector.cs:41-97), and
- decides applied vs not via
GetAppliedInterests → IsTargetOf, which hard-codes participants.target == entity (InterestBadgeProjector.cs:113-149). It does not check the user participant, so applied-detection is not scoped to the current identity today.
Toggling is done by InterestToggle.ToggleAsync(EntityBroker, EntitySnapshot, string interestTypeName, CancellationToken) (Phantom.Workspaces/InterestToggle.cs:17-81), which:
- detects an existing interest via
IsInterestTargeting — again hard-codes participants.target == entity (InterestToggle.cs:94-111), and
- creates a relationship of the fixed shape
participants: { target: <entity>, user: <userId> }, where userId = entityBroker.EntityRepository.WorkspaceEntitySession.UserEntityId (the user entity, NOT the profile) (InterestToggle.cs:53-63).
- deletion uses
EntityChange { Data = null, EntityChangeMode = Replace } (InterestToggle.cs:33-49).
EntityBroker.ToggleInterestAsync (EntityBroker.cs:611-616) currently passes a bare interestTypeName string.
Relationship loading for views
MainWindowViewModel.WithInterestRelationships (MainWindowViewModel.cs:1377-1390) already augments a query's RelationshipsToReturn with all InterestCatalog.InterestTypeNames plus related:
internal static QueryRequest WithInterestRelationships(QueryRequest query, InterestCatalog? catalog)
{
return query with
{
RelationshipsToReturn =
[
..(query.RelationshipsToReturn ?? []),
new GetRelationshipRequest { RelationshipTypeNames = new RelationshipTypeNameSet(["related"]) },
..(catalog is { InterestTypeNames.Count: > 0 } validCatalog
? [new GetRelationshipRequest { RelationshipTypeNames = new RelationshipTypeNameSet([.. validCatalog.InterestTypeNames]) }]
: Array.Empty<GetRelationshipRequest>()),
],
};
}
However, the view-definition-driven query builders do not route through it: TryReadSubViewGetRequest (MainWindowViewModel.cs:3661-3696, taking relationships-to-return at :3689), TryReadSubViewQueryRequest (:3699-3723, at :3718) and TryReadGetEntityRequest (:3725-3780, at :3774) take relationships-to-return verbatim from the view JSON. Because those paths bypass WithInterestRelationships, some views load only the relationship types the JSON names, so an interest badge (including the new default) may not reflect current state on every view.
The unit WithInterestRelationships (:1377) is applied to plain query sub-views at MainWindowViewModel.cs:1364, but the GetRequest/GetEntityRequest builders (TryReadSubViewGetRequest/TryReadGetEntityRequest) never reach that call site, so their generated requests omit the interest relationship types entirely.
Why a plain registration is insufficient
Merely adding "interest-type" to default-entity-type.json would make the badge appear, but:
- applied-detection would never fire (
IsTargetOf looks for participants.target, but default uses participants.value), and
- toggling would create a malformed
default relationship (it would write {target, user} instead of {value, applied-to}).
The generic machinery must therefore be made data-driven about (a) which participant references the badged entity, and (b) which participant(s) scope the interest and to which session identity they bind. Because these two attributes are made required, the existing interest definitions must also be updated to declare their {target, user} shape explicitly.
Affected Files
| File |
Contribution |
Phantom.Workspaces.Data.Core/JsonSchemas/interest-type.json |
Add required declarative participant-mapping properties: target-participant (participant naming the badged entity) and applies-to (array of scope-participant descriptors: participant-property-name, optional entity-types, and session-value). Add both to the top-level required list. No singleton property. |
.../schema-definitions/actionable-entity-type.json |
Add target-participant: "target" and applies-to: [{ participant-property-name: "user", entity-types: ["user"], session-value: "user-entity-id" }] so the existing interest satisfies the now-required attributes. |
.../schema-definitions/blocked-entity-type.json |
Same explicit {target, user} mapping as actionable. |
.../schema-definitions/assigned-to-entity-type.json |
Same explicit {target, user} mapping. |
.../schema-definitions/not-interesting-entity-type.json |
Same explicit {target, user} mapping. |
.../schema-definitions/default-entity-type.json |
Add "interest-type" and "relationship-type" to entity-types; add applied/notApplied indicators; "display-entity-types": ["workspace"]; target-participant: "value"; applies-to: [{ participant-property-name: "applied-to", entity-types: ["user-computer-profile"], session-value: "user-computer-profile-entity-id" }]. Registration point for the new interest. No singleton. |
Phantom.Workspaces/InterestCatalog.cs |
Extend InterestTypeDefinition with required TargetParticipant and AppliesTo (list of { ParticipantPropertyName, EntityTypes, SessionValue }); parse them in TryReadInterestType (:115-138). No Singleton field. |
Phantom.Workspaces/InterestBadgeProjector.cs |
Replace hard-coded IsTargetOf (:137-149) with data-driven matching: applied when participants[TargetParticipant] == entity AND every AppliesTo participant equals the corresponding current-session identity. Project (:18-39) gains the session identities (user id + profile id). |
Phantom.Workspaces/InterestToggle.cs |
Replace hard-coded {target, user} create/detect (:17-111) with data-driven logic: resolve AppliesTo participant values from the session; detect existing by TargetParticipant == entity AND matching AppliesTo; toggle-off deletes it; toggle-on writes TargetParticipant + AppliesTo participants. No singleton clearing. Accept an InterestTypeDefinition instead of a bare name. |
Phantom.Workspaces/EntityBroker.cs |
ToggleInterestAsync (:611-616) resolves the InterestTypeDefinition from the catalog by name and passes it to InterestToggle.ToggleAsync. |
Phantom.Workspaces/ViewModels/MainWindowViewModel.cs |
Route the view-definition query builders (TryReadSubViewGetRequest :3661, TryReadSubViewQueryRequest :3699, TryReadGetEntityRequest :3725) through the interest-relationship augmentation so every view always loads all interest relationship types (verified by the view-query tests requested in issue feedback). Pass the session identities into InterestBadgeProjector.Project at the projection sites (:1536, :1545). |
Design / Fix
Chosen semantics
- Per-profile, multi-profile: a
default relationship is scoped to a user-computer-profile via its applied-to participant. Any profile may mark a workspace as its default independently; toggling for the current profile never touches another profile's default relationships.
- No singleton: toggling
default on creates a default relationship for the current profile; toggling off deletes it. The feature does not auto-clear a profile's other defaults — if a profile has more than one default, the existing startup read path (QueryDefaultWorkspaceIdsAsync) already returns them all. Any "only one default" policy is out of scope for this issue.
- Required, explicit mapping:
target-participant and applies-to are required on every interest-type. Existing interests declare their {target, user} shape explicitly (no code-side defaults). Behaviour is entirely determined by declared metadata — no code branches on the literal name default.
1. Generalise the interest-type schema (required participant mapping)
Extend interest-type.json with required properties (no singleton):
Add target-participant and applies-to to the top-level required array (alongside entity-types, applied, notApplied). Because the attributes are required, there are no implicit code-side defaults — every interest definition must supply them.
2. Update the existing interests to declare {target, user} explicitly
Each of actionable-entity-type.json, blocked-entity-type.json, assigned-to-entity-type.json, not-interesting-entity-type.json gains:
This reproduces today's behaviour (target entity + user == current user) as explicit data, satisfying the now-required schema.
3. Register default as an interest
Edit default-entity-type.json to also be an interest-type + relationship-type, scoped to workspaces, with the {value, applied-to} mapping:
display-entity-types: ["workspace"] makes ShouldShowBadge (InterestBadgeProjector.cs:41-97) show the badge only on workspace entities. InterestCatalog.CreateAsync discovers it automatically because it now carries interest-type, and the interest-relationship augmentation therefore includes default in every view's RelationshipsToReturn.
4. Extend InterestTypeDefinition + parsing (InterestCatalog.cs)
public sealed record InterestAppliesTo(
string ParticipantPropertyName,
IReadOnlySet<string>? EntityTypes,
InterestSessionValue SessionValue);
public enum InterestSessionValue { UserEntityId, UserComputerProfileEntityId }
public sealed record InterestTypeDefinition(
string Name,
string AppliedGlyph, string NotAppliedGlyph,
string AppliedDescription, string NotAppliedDescription,
string AppliedActionText, string NotAppliedActionText,
IReadOnlySet<string>? DisplayEntityTypes,
string TargetParticipant, // required
IReadOnlyList<InterestAppliesTo> AppliesTo); // required
TryReadInterestType (InterestCatalog.cs:115-138) reads the required target-participant and applies-to[] (mapping session-value string → InterestSessionValue). A definition missing them is malformed data; treat consistently with existing parse-failure handling.
5. Data-driven applied-detection (InterestBadgeProjector.cs)
Project gains the current session identities; GetAppliedInterests/IsTargetOf are replaced by generic matching:
public static IReadOnlyList<BadgeModel> Project(
InterestCatalog interestCatalog,
EntityTypeCatalog entityTypeCatalog,
EntitySnapshot entity,
EntityId userId,
EntityId userComputerProfileId) { ... }
private static bool IsAppliedTo(
JsonElement rel, EntityId entityId, InterestTypeDefinition interest,
EntityId userId, EntityId profileId)
{
if (!TryParticipant(rel, interest.TargetParticipant, out var target)
|| !string.Equals(target, entityId.ToString(), StringComparison.OrdinalIgnoreCase))
return false;
foreach (var scope in interest.AppliesTo)
{
var expected = scope.SessionValue == InterestSessionValue.UserComputerProfileEntityId
? profileId.ToString() : userId.ToString();
if (!TryParticipant(rel, scope.ParticipantPropertyName, out var actual)
|| !string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase))
return false;
}
return true;
}
This makes standard interests apply only when target == entity AND user == current user (tighter than today, which ignored user), and treats a default relationship as applied only when value == workspace AND applied-to == currentProfile — so another profile's default reads as inactive. The projection call sites (MainWindowViewModel.cs:1536, :1545) pass WorkspaceEntitySession.UserEntityId and .UserComputerProfileEntityId.
6. Data-driven toggle, no singleton (InterestToggle.cs)
public static async Task ToggleAsync(EntityBroker entityBroker, EntitySnapshot entity,
InterestTypeDefinition interest, CancellationToken ct = default)
{
var session = entityBroker.EntityRepository.WorkspaceEntitySession;
EntityId Resolve(InterestSessionValue v) =>
v == InterestSessionValue.UserComputerProfileEntityId
? session.UserComputerProfileEntityId : session.UserEntityId;
// Existing relationship: target participant == entity AND all applies-to == session scope.
var existing = entity.Relationships.FirstOrDefault(r =>
r.Data is JsonElement d && MatchesTargetAndScope(d, entity.EntityId, interest, Resolve));
if (existing is not null) { /* delete (Data=null, Replace) — toggle OFF; return */ }
// Toggle ON: build participants from TargetParticipant + AppliesTo(session values), then create.
var participants = new Dictionary<string, string> { [interest.TargetParticipant] = entity.EntityId.Value.ToString() };
foreach (var s in interest.AppliesTo) participants[s.ParticipantPropertyName] = Resolve(s.SessionValue).Value.ToString();
// entity-types: ["entity", interest.Name, "relationship"]; participants = participants; note = "Toggled from the entity badge."
}
Deletion reuses the existing EntityChange { Data = null, EntityChangeMode = Replace } pattern (InterestToggle.cs:33-49). There is no singleton clearing step — toggling a new default on does not remove a profile's other defaults. EntityBroker.ToggleInterestAsync (EntityBroker.cs:611-616) resolves the InterestTypeDefinition from the catalog by name and passes it in (instead of a bare string).
7. Centralise interest relationships on every view
Route the view-definition query builders through the same interest-relationship augmentation used by WithInterestRelationships (MainWindowViewModel.cs:1377) so all interest types (now including default) are always requested, merging with (not replacing) the JSON-declared relationships-to-return:
TryReadSubViewGetRequest (MainWindowViewModel.cs:3661-3696)
TryReadSubViewQueryRequest (:3699-3723)
TryReadGetEntityRequest (:3725-3780)
Each merges the JSON-declared relationships-to-return with the catalog's interest relationship types rather than using the JSON list verbatim, so every view can display and toggle every interest. WithInterestRelationships already merges related + all InterestCatalog.InterestTypeNames; the GetRequest/GetEntityRequest builders gain an equivalent merge (the current query-subview path only reaches WithInterestRelationships at :1364).
Test requirement (per issue feedback): the generated queries/requests produced from a View definition must be tested to ensure that all existing interests are retrieved — not merely the relationship types named in the view JSON. This means asserting that the request built by each of the three builders (and the merge helper WithInterestRelationships) requests every InterestCatalog.InterestTypeNames entry (actionable, blocked, assigned-to, not-interesting, and the new default) in addition to any JSON-declared relationships. See the view-query rows in Expected Tests.
Considered / Background (not chosen)
- Optional attributes with implicit code-side defaults. A previous revision made
target-participant/applies-to optional and had InterestCatalog synthesise the {target, user} shape when absent, leaving existing interest JSON untouched. Rejected per issue feedback ("set the attributes as required and update the current interests to provide those values"): the attributes are now required and every existing interest definition declares them explicitly.
singleton / singleton-per-scope semantics. A previous revision added a singleton attribute and had the toggle clear a profile's prior default before adding a new one. Rejected per issue feedback ("Let's not add 'singleton' behavior"): no singleton attribute, no auto-clear. Multiple defaults per profile are permitted; the startup read path already returns all of them.
- Per-name special-casing in code (an
if (interestTypeName == "default") branch that hard-codes the {value, applied-to} shape). Rejected: it does not scale to future participant-shaped interests and duplicates shape knowledge across InterestBadgeProjector and InterestToggle. The chosen design moves the shape into declarative interest-type metadata so one generic code path serves all interests.
- Multi-default modeled as a plain boolean per workspace (no scope participant). Rejected: the read path is keyed on
applied-to == profile, so scope must be a participant, not a bare flag.
Expected Tests
Model on InterestBadgeProjectorTests (xUnit [Fact], static InterestCatalog built from InterestTypeDefinition literals — e.g. new InterestTypeDefinition("actionable", "❗", "○", "Actionable", "Not actionable", "Mark actionable", "Clear actionable", null, ...)), InterestToggleTests ([AvaloniaFact], EntityBroker.CreateInitializedAsync, SeedAsync + GetWithInterestsAsync), SchemaPopulatorTests (xUnit [Fact]), and MainWindowIntegrationTests ([AvaloniaFact], profile via WorkspaceEntitySession.UserComputerProfileEntityId, SeedEntityAsync). Existing InterestTypeDefinition literals in InterestBadgeProjectorTests gain the new required positional args (TargetParticipant, AppliesTo) with the standard {target, user} values.
View-generated-query coverage (per issue feedback). The generated queries/requests produced from a View definition must be tested to ensure all existing interests are retrieved. Reuse the existing view-query test classes and their real style:
SubViewRelationshipMergeTests (xUnit [Fact]) — directly exercises MainWindowViewModel.WithInterestRelationships, building an InterestCatalog from InterestTypeDefinition literals (new([new InterestTypeDefinition("interest-a", "●", "○", "", "", "", "", null)])) and asserting the merged RelationshipsToReturn type-name set via result.RelationshipsToReturn!.Select(r => r.RelationshipTypeNames?.Values ?? []).SelectMany(v => v).ToHashSet().
TryReadSubViewQueryRequestTests (xUnit [Fact]) — invokes the private view builders by reflection (typeof(MainWindowViewModel).GetMethod("TryReadSubViewQueryRequest", BindingFlags.Static | BindingFlags.NonPublic)) on view-definition JSON, then asserts the generated request's RelationshipsToReturn after augmentation. Add sibling reflection helpers for TryReadSubViewGetRequest and TryReadGetEntityRequest.
InterestViewQueryTests ([AvaloniaFact]) — end-to-end: seeds an in-memory EntityRepository, drives a view definition, and asserts the executed query requested every interest relationship type.
New view-query tests must assert that every InterestCatalog.InterestTypeNames entry (actionable, blocked, assigned-to, not-interesting, default) appears in the generated request, and that JSON-declared relationships-to-return entries are preserved alongside them.
| Test Name |
Class |
What It Verifies |
Project_DefaultInterest_ShowsOnWorkspaceEntities |
InterestBadgeProjectorTests |
The default interest badge is projected for a workspace entity via display-entity-types. |
Project_DefaultInterest_DoesNotShowOnNonWorkspaceEntities |
InterestBadgeProjectorTests |
The default badge is filtered out for non-workspace entity types (e.g. task). |
Project_ConfiguredTargetParticipant_MarksActiveWhenValueMatchesEntity |
InterestBadgeProjectorTests |
A default relationship with value == workspace and applied-to == currentProfile projects as applied/active. |
Project_AppliesToScope_MarksInactiveWhenAppliedToIsAnotherProfile |
InterestBadgeProjectorTests |
A default relationship whose applied-to is a different profile is not treated as applied. |
Project_StandardInterest_MarksInactiveWhenUserParticipantIsAnotherUser |
InterestBadgeProjectorTests |
The now-explicit {target, user} applies-to: an interest whose user participant differs from the session user reads as inactive. |
ToggleAsync_DefaultInterest_WhenNotDefault_CreatesRelationshipWithValueAndAppliedToParticipants |
InterestToggleTests |
Toggling on creates a default relationship { value: workspace, applied-to: currentProfile }. |
ToggleAsync_DefaultInterest_WhenAlreadyDefault_RemovesRelationship |
InterestToggleTests |
Toggling off removes the workspace's default relationship for the current profile. |
ToggleAsync_DefaultInterest_WhenAnotherWorkspaceIsDefaultForSameProfile_LeavesExistingDefaultIntact |
InterestToggleTests |
No singleton: toggling a second workspace default on does not remove the profile's other default relationship. |
ToggleAsync_DefaultInterest_WhenDefaultExistsForAnotherProfile_LeavesOtherProfileDefaultIntact |
InterestToggleTests |
Multi-profile: toggling for the current profile does not remove another profile's default relationship. |
ToggleAsync_DefaultInterest_AddsThenRemovesTheDefaultRelationship |
InterestToggleTests |
Round-trip on→off leaves no default relationship (mirrors ToggleAsync_AddsThenRemovesTheInterestRelationship). |
ToggleAsync_StandardInterest_StillWritesTargetAndUserParticipants |
InterestToggleTests |
Regression: the generalised toggle preserves the {target, user} shape for the standard interests. |
Populate_RegistersDefaultAsInterestTypeWithValueAppliedToMapping |
SchemaPopulatorTests |
The seeded default-entity-type validates as an interest-type and carries target-participant: "value" + applies-to bound to user-computer-profile. |
Populate_ExistingInterestTypes_DeclareTargetAndUserAppliesTo |
SchemaPopulatorTests |
The seeded actionable/blocked/assigned-to/not-interesting interest types each declare the required target-participant: "target" and applies-to (user → user-entity-id). |
InitializeAsync_AfterTogglingDefaultInterest_OpensDefaultWorkspace |
MainWindowIntegrationTests |
After toggling the default interest on for a workspace, startup opens that workspace (integrates with InitializeAsync_WithDefaultRelationship_OpensDefaultWorkspace at MainWindowIntegrationTests.cs:4700). |
WithInterestRelationships_WithAllExistingInterests_RequestsEveryInterestRelationshipType |
SubViewRelationshipMergeTests |
Given a catalog containing all existing interests (actionable, blocked, assigned-to, not-interesting, default), the merged query's RelationshipsToReturn requests every one (plus related). |
WithInterestRelationships_WithJsonDeclaredRelationships_PreservesThemAlongsideInterests |
SubViewRelationshipMergeTests |
JSON-declared relationships-to-return entries survive the merge and coexist with the added interest relationship types (including default). |
TryReadSubViewQueryRequest_ForViewDefinition_GeneratedQueryRetrievesEveryInterestType |
TryReadSubViewQueryRequestTests |
A query built from a view definition is augmented so every interest relationship type — including default — is retrieved, not only those named in the view JSON. |
TryReadSubViewGetRequest_ForViewDefinition_GeneratedGetRetrievesEveryInterestType |
TryReadSubViewQueryRequestTests |
A GetRequest built from a view definition is augmented so every interest relationship type (incl. default) is retrieved. |
TryReadGetEntityRequest_ForViewDefinition_GeneratedGetEntityRetrievesEveryInterestType |
TryReadSubViewQueryRequestTests |
A GetEntityRequest built from a view definition retrieves every interest relationship type (incl. default) in addition to its JSON-declared relationships. |
InboxView_GeneratedQuery_RetrievesAllInterestRelationshipTypesIncludingDefault |
InterestViewQueryTests |
End-to-end: the view-definition query the view model executes requests every interest relationship type (incl. default), so all interest badges reflect current state on that view. |
Summary
Expose the "default" workspace relationship as a toggleable interest badge on workspace entity cards, so a user can mark/unmark a workspace as their default for the current user-computer profile from the standard interest-toggle UI (the same affordance used for
actionable,blocked, etc.). Today a "default workspace" is modeled by adefaultrelationship (participants.applied-to= the user-computer profile,participants.value= the workspace) that is read at startup to auto-open the default workspace, but there is no UI to set or clear it — it can only be seeded as data.Rather than special-casing
defaultin the interest code, this enhancement generalises the interest mechanism to be data-driven: aninterest-typedefinition declares which participant references the badged entity (target-participant) and which participant(s) scope it and to which session identity they bind (applies-to). These two attributes are required on everyinterest-type(there are no implicit defaults in code): the existing interests (actionable,blocked,assigned-to,not-interesting) are updated to declare their current{target, user}shape explicitly, anddefaultregisters withtarget-participant: "value"and anapplies-tobinding itsapplied-toparticipant to the currentuser-computer-profile. The same generic projector/toggle then serves every interest with no per-name branching.This is a feature/enhancement, not a defect.
Current State / Root Cause
The "default" relationship (how a default workspace is modeled today)
A default workspace is a relationship entity of type
default. Its schema requiresapplied-toandvalueparticipants (NOT the generictarget/usershape used by interests):Phantom.Workspaces.Data.Core/JsonSchemas/default.json(participants block)The relationship-type definition entity is
default-entity-type.json(entity-idb1d4f3a2-8c5e-4b7d-9f6a-1e2d3c4b5a6f), currently typed only asentity/entity-type/json-schema/note— neitherinterest-typenorrelationship-type:Phantom.Workspaces.Data.Core/JsonEntities/schema-definitions/default-entity-type.jsonThe default workspace is read at startup and on closing the last workspace, keyed on the current user-computer profile:
Phantom.Workspaces/ViewModels/MainWindowViewModel.cs:3501-3557(QueryDefaultWorkspaceIdsAsync)The read path returns every
defaultwhoseapplied-to == currentProfile(QueryDefaultWorkspaceIdsAsynccollects a list of workspace ids). Callers atMainWindowViewModel.cs:3410(initialize / open default) and:3483(fall back to default when the last workspace closes). Nothing writes this relationship from the UI — it is data-only today.Scope semantics. The default is per user-computer profile: the read path is keyed on
applied-to == currentProfile. Any profile may independently mark a workspace as its default, and marking a default for one profile must not disturb another profile's default.The "interest" mechanism (how toggleable badges work today)
An interest type is defined by an
interest-typeentity carryingapplied/notAppliedbadge metadata and optionaldisplay-entity-types. Its schema is:Phantom.Workspaces.Data.Core/JsonSchemas/interest-type.json—allOfrelationship-type.json; requiredentity-types,applied,notApplied. There is no top-levelunevaluatedProperties:false/additionalProperties:false(only the nestedinterest-state-contentdef setsunevaluatedProperties:false), so additional declared top-level properties are permitted.InterestCatalogdiscovers interest types via a live query forentity-types = interest-type, and exposes their names centrally:Phantom.Workspaces/InterestCatalog.cs:17-25—InterestTypeDefinition(Name, AppliedGlyph, NotAppliedGlyph, AppliedDescription, NotAppliedDescription, AppliedActionText, NotAppliedActionText, DisplayEntityTypes)(8 positional fields).InterestCatalog.cs:115-138(TryReadInterestType)InterestCatalog.cs:63-80(CreateAsync, subscribes tointerest-typeentities)InterestCatalog.cs:57(InterestTypeNames)"interest-type"+"relationship-type"):actionable-entity-type.json,blocked-entity-type.json,assigned-to-entity-type.json,not-interesting-entity-type.json. Their relationship schemas (actionable.json,blocked.json,assigned-to.json,not-interesting.json) use participants{ target, user }(plus optionalviewfor actionable/blocked), required["target", "user"],userconstrained tox-entity-types: ["user"].Badges are projected per entity by
InterestBadgeProjector.Project(Phantom.Workspaces/InterestBadgeProjector.cs:18-39), which:ShouldShowBadgeusingdisplay-entity-types/display-interest-types(InterestBadgeProjector.cs:41-97), andGetAppliedInterests→IsTargetOf, which hard-codesparticipants.target == entity(InterestBadgeProjector.cs:113-149). It does not check theuserparticipant, so applied-detection is not scoped to the current identity today.Toggling is done by
InterestToggle.ToggleAsync(EntityBroker, EntitySnapshot, string interestTypeName, CancellationToken)(Phantom.Workspaces/InterestToggle.cs:17-81), which:IsInterestTargeting— again hard-codesparticipants.target == entity(InterestToggle.cs:94-111), andparticipants: { target: <entity>, user: <userId> }, whereuserId = entityBroker.EntityRepository.WorkspaceEntitySession.UserEntityId(the user entity, NOT the profile) (InterestToggle.cs:53-63).EntityChange { Data = null, EntityChangeMode = Replace }(InterestToggle.cs:33-49).EntityBroker.ToggleInterestAsync(EntityBroker.cs:611-616) currently passes a bareinterestTypeNamestring.Relationship loading for views
MainWindowViewModel.WithInterestRelationships(MainWindowViewModel.cs:1377-1390) already augments a query'sRelationshipsToReturnwith allInterestCatalog.InterestTypeNamesplusrelated:However, the view-definition-driven query builders do not route through it:
TryReadSubViewGetRequest(MainWindowViewModel.cs:3661-3696, takingrelationships-to-returnat:3689),TryReadSubViewQueryRequest(:3699-3723, at:3718) andTryReadGetEntityRequest(:3725-3780, at:3774) takerelationships-to-returnverbatim from the view JSON. Because those paths bypassWithInterestRelationships, some views load only the relationship types the JSON names, so an interest badge (including the newdefault) may not reflect current state on every view.The unit
WithInterestRelationships(:1377) is applied to plain query sub-views atMainWindowViewModel.cs:1364, but theGetRequest/GetEntityRequestbuilders (TryReadSubViewGetRequest/TryReadGetEntityRequest) never reach that call site, so their generated requests omit the interest relationship types entirely.Why a plain registration is insufficient
Merely adding
"interest-type"todefault-entity-type.jsonwould make the badge appear, but:IsTargetOflooks forparticipants.target, butdefaultusesparticipants.value), anddefaultrelationship (it would write{target, user}instead of{value, applied-to}).The generic machinery must therefore be made data-driven about (a) which participant references the badged entity, and (b) which participant(s) scope the interest and to which session identity they bind. Because these two attributes are made required, the existing interest definitions must also be updated to declare their
{target, user}shape explicitly.Affected Files
Phantom.Workspaces.Data.Core/JsonSchemas/interest-type.jsontarget-participant(participant naming the badged entity) andapplies-to(array of scope-participant descriptors:participant-property-name, optionalentity-types, andsession-value). Add both to the top-levelrequiredlist. Nosingletonproperty..../schema-definitions/actionable-entity-type.jsontarget-participant: "target"andapplies-to: [{ participant-property-name: "user", entity-types: ["user"], session-value: "user-entity-id" }]so the existing interest satisfies the now-required attributes..../schema-definitions/blocked-entity-type.json{target, user}mapping as actionable..../schema-definitions/assigned-to-entity-type.json{target, user}mapping..../schema-definitions/not-interesting-entity-type.json{target, user}mapping..../schema-definitions/default-entity-type.json"interest-type"and"relationship-type"toentity-types; addapplied/notAppliedindicators;"display-entity-types": ["workspace"];target-participant: "value";applies-to: [{ participant-property-name: "applied-to", entity-types: ["user-computer-profile"], session-value: "user-computer-profile-entity-id" }]. Registration point for the new interest. Nosingleton.Phantom.Workspaces/InterestCatalog.csInterestTypeDefinitionwith requiredTargetParticipantandAppliesTo(list of{ ParticipantPropertyName, EntityTypes, SessionValue }); parse them inTryReadInterestType(:115-138). NoSingletonfield.Phantom.Workspaces/InterestBadgeProjector.csIsTargetOf(:137-149) with data-driven matching: applied whenparticipants[TargetParticipant] == entityAND everyAppliesToparticipant equals the corresponding current-session identity.Project(:18-39) gains the session identities (user id + profile id).Phantom.Workspaces/InterestToggle.cs{target, user}create/detect (:17-111) with data-driven logic: resolveAppliesToparticipant values from the session; detect existing byTargetParticipant == entityAND matchingAppliesTo; toggle-off deletes it; toggle-on writesTargetParticipant+AppliesToparticipants. No singleton clearing. Accept anInterestTypeDefinitioninstead of a bare name.Phantom.Workspaces/EntityBroker.csToggleInterestAsync(:611-616) resolves theInterestTypeDefinitionfrom the catalog by name and passes it toInterestToggle.ToggleAsync.Phantom.Workspaces/ViewModels/MainWindowViewModel.csTryReadSubViewGetRequest:3661,TryReadSubViewQueryRequest:3699,TryReadGetEntityRequest:3725) through the interest-relationship augmentation so every view always loads all interest relationship types (verified by the view-query tests requested in issue feedback). Pass the session identities intoInterestBadgeProjector.Projectat the projection sites (:1536,:1545).Design / Fix
Chosen semantics
defaultrelationship is scoped to auser-computer-profilevia itsapplied-toparticipant. Any profile may mark a workspace as its default independently; toggling for the current profile never touches another profile'sdefaultrelationships.defaulton creates adefaultrelationship for the current profile; toggling off deletes it. The feature does not auto-clear a profile's other defaults — if a profile has more than onedefault, the existing startup read path (QueryDefaultWorkspaceIdsAsync) already returns them all. Any "only one default" policy is out of scope for this issue.target-participantandapplies-toare required on everyinterest-type. Existing interests declare their{target, user}shape explicitly (no code-side defaults). Behaviour is entirely determined by declared metadata — no code branches on the literal namedefault.1. Generalise the interest-type schema (required participant mapping)
Extend
interest-type.jsonwith required properties (nosingleton):Add
target-participantandapplies-toto the top-levelrequiredarray (alongsideentity-types,applied,notApplied). Because the attributes are required, there are no implicit code-side defaults — every interest definition must supply them.2. Update the existing interests to declare
{target, user}explicitlyEach of
actionable-entity-type.json,blocked-entity-type.json,assigned-to-entity-type.json,not-interesting-entity-type.jsongains:This reproduces today's behaviour (target entity +
user == current user) as explicit data, satisfying the now-required schema.3. Register
defaultas an interestEdit
default-entity-type.jsonto also be aninterest-type+relationship-type, scoped to workspaces, with the{value, applied-to}mapping:display-entity-types: ["workspace"]makesShouldShowBadge(InterestBadgeProjector.cs:41-97) show the badge only onworkspaceentities.InterestCatalog.CreateAsyncdiscovers it automatically because it now carriesinterest-type, and the interest-relationship augmentation therefore includesdefaultin every view'sRelationshipsToReturn.4. Extend
InterestTypeDefinition+ parsing (InterestCatalog.cs)TryReadInterestType(InterestCatalog.cs:115-138) reads the requiredtarget-participantandapplies-to[](mappingsession-valuestring →InterestSessionValue). A definition missing them is malformed data; treat consistently with existing parse-failure handling.5. Data-driven applied-detection (
InterestBadgeProjector.cs)Projectgains the current session identities;GetAppliedInterests/IsTargetOfare replaced by generic matching:This makes standard interests apply only when
target == entityANDuser == current user(tighter than today, which ignoreduser), and treats adefaultrelationship as applied only whenvalue == workspaceANDapplied-to == currentProfile— so another profile's default reads as inactive. The projection call sites (MainWindowViewModel.cs:1536,:1545) passWorkspaceEntitySession.UserEntityIdand.UserComputerProfileEntityId.6. Data-driven toggle, no singleton (
InterestToggle.cs)Deletion reuses the existing
EntityChange { Data = null, EntityChangeMode = Replace }pattern (InterestToggle.cs:33-49). There is no singleton clearing step — toggling a newdefaulton does not remove a profile's other defaults.EntityBroker.ToggleInterestAsync(EntityBroker.cs:611-616) resolves theInterestTypeDefinitionfrom the catalog by name and passes it in (instead of a bare string).7. Centralise interest relationships on every view
Route the view-definition query builders through the same interest-relationship augmentation used by
WithInterestRelationships(MainWindowViewModel.cs:1377) so all interest types (now includingdefault) are always requested, merging with (not replacing) the JSON-declaredrelationships-to-return:TryReadSubViewGetRequest(MainWindowViewModel.cs:3661-3696)TryReadSubViewQueryRequest(:3699-3723)TryReadGetEntityRequest(:3725-3780)Each merges the JSON-declared
relationships-to-returnwith the catalog's interest relationship types rather than using the JSON list verbatim, so every view can display and toggle every interest.WithInterestRelationshipsalready mergesrelated+ allInterestCatalog.InterestTypeNames; theGetRequest/GetEntityRequestbuilders gain an equivalent merge (the current query-subview path only reachesWithInterestRelationshipsat:1364).Considered / Background (not chosen)
target-participant/applies-tooptional and hadInterestCatalogsynthesise the{target, user}shape when absent, leaving existing interest JSON untouched. Rejected per issue feedback ("set the attributes as required and update the current interests to provide those values"): the attributes are now required and every existing interest definition declares them explicitly.singleton/ singleton-per-scope semantics. A previous revision added asingletonattribute and had the toggle clear a profile's prior default before adding a new one. Rejected per issue feedback ("Let's not add 'singleton' behavior"): no singleton attribute, no auto-clear. Multiple defaults per profile are permitted; the startup read path already returns all of them.if (interestTypeName == "default")branch that hard-codes the{value, applied-to}shape). Rejected: it does not scale to future participant-shaped interests and duplicates shape knowledge acrossInterestBadgeProjectorandInterestToggle. The chosen design moves the shape into declarativeinterest-typemetadata so one generic code path serves all interests.applied-to == profile, so scope must be a participant, not a bare flag.Expected Tests
Model on
InterestBadgeProjectorTests(xUnit[Fact], staticInterestCatalogbuilt fromInterestTypeDefinitionliterals — e.g.new InterestTypeDefinition("actionable", "❗", "○", "Actionable", "Not actionable", "Mark actionable", "Clear actionable", null, ...)),InterestToggleTests([AvaloniaFact],EntityBroker.CreateInitializedAsync,SeedAsync+GetWithInterestsAsync),SchemaPopulatorTests(xUnit[Fact]), andMainWindowIntegrationTests([AvaloniaFact], profile viaWorkspaceEntitySession.UserComputerProfileEntityId,SeedEntityAsync). ExistingInterestTypeDefinitionliterals inInterestBadgeProjectorTestsgain the new required positional args (TargetParticipant,AppliesTo) with the standard{target, user}values.View-generated-query coverage (per issue feedback). The generated queries/requests produced from a View definition must be tested to ensure all existing interests are retrieved. Reuse the existing view-query test classes and their real style:
SubViewRelationshipMergeTests(xUnit[Fact]) — directly exercisesMainWindowViewModel.WithInterestRelationships, building anInterestCatalogfromInterestTypeDefinitionliterals (new([new InterestTypeDefinition("interest-a", "●", "○", "", "", "", "", null)])) and asserting the mergedRelationshipsToReturntype-name set viaresult.RelationshipsToReturn!.Select(r => r.RelationshipTypeNames?.Values ?? []).SelectMany(v => v).ToHashSet().TryReadSubViewQueryRequestTests(xUnit[Fact]) — invokes the private view builders by reflection (typeof(MainWindowViewModel).GetMethod("TryReadSubViewQueryRequest", BindingFlags.Static | BindingFlags.NonPublic)) on view-definition JSON, then asserts the generated request'sRelationshipsToReturnafter augmentation. Add sibling reflection helpers forTryReadSubViewGetRequestandTryReadGetEntityRequest.InterestViewQueryTests([AvaloniaFact]) — end-to-end: seeds an in-memoryEntityRepository, drives a view definition, and asserts the executed query requested every interest relationship type.New view-query tests must assert that every
InterestCatalog.InterestTypeNamesentry (actionable,blocked,assigned-to,not-interesting,default) appears in the generated request, and that JSON-declaredrelationships-to-returnentries are preserved alongside them.Project_DefaultInterest_ShowsOnWorkspaceEntitiesInterestBadgeProjectorTestsdefaultinterest badge is projected for aworkspaceentity viadisplay-entity-types.Project_DefaultInterest_DoesNotShowOnNonWorkspaceEntitiesInterestBadgeProjectorTestsdefaultbadge is filtered out for non-workspace entity types (e.g.task).Project_ConfiguredTargetParticipant_MarksActiveWhenValueMatchesEntityInterestBadgeProjectorTestsdefaultrelationship withvalue == workspaceandapplied-to == currentProfileprojects as applied/active.Project_AppliesToScope_MarksInactiveWhenAppliedToIsAnotherProfileInterestBadgeProjectorTestsdefaultrelationship whoseapplied-tois a different profile is not treated as applied.Project_StandardInterest_MarksInactiveWhenUserParticipantIsAnotherUserInterestBadgeProjectorTests{target, user}applies-to: an interest whoseuserparticipant differs from the session user reads as inactive.ToggleAsync_DefaultInterest_WhenNotDefault_CreatesRelationshipWithValueAndAppliedToParticipantsInterestToggleTestsdefaultrelationship{ value: workspace, applied-to: currentProfile }.ToggleAsync_DefaultInterest_WhenAlreadyDefault_RemovesRelationshipInterestToggleTestsdefaultrelationship for the current profile.ToggleAsync_DefaultInterest_WhenAnotherWorkspaceIsDefaultForSameProfile_LeavesExistingDefaultIntactInterestToggleTestsdefaultrelationship.ToggleAsync_DefaultInterest_WhenDefaultExistsForAnotherProfile_LeavesOtherProfileDefaultIntactInterestToggleTestsdefaultrelationship.ToggleAsync_DefaultInterest_AddsThenRemovesTheDefaultRelationshipInterestToggleTestsdefaultrelationship (mirrorsToggleAsync_AddsThenRemovesTheInterestRelationship).ToggleAsync_StandardInterest_StillWritesTargetAndUserParticipantsInterestToggleTests{target, user}shape for the standard interests.Populate_RegistersDefaultAsInterestTypeWithValueAppliedToMappingSchemaPopulatorTestsdefault-entity-typevalidates as aninterest-typeand carriestarget-participant: "value"+applies-tobound touser-computer-profile.Populate_ExistingInterestTypes_DeclareTargetAndUserAppliesToSchemaPopulatorTestsactionable/blocked/assigned-to/not-interestinginterest types each declare the requiredtarget-participant: "target"andapplies-to(user→user-entity-id).InitializeAsync_AfterTogglingDefaultInterest_OpensDefaultWorkspaceMainWindowIntegrationTestsdefaultinterest on for a workspace, startup opens that workspace (integrates withInitializeAsync_WithDefaultRelationship_OpensDefaultWorkspaceatMainWindowIntegrationTests.cs:4700).WithInterestRelationships_WithAllExistingInterests_RequestsEveryInterestRelationshipTypeSubViewRelationshipMergeTestsactionable,blocked,assigned-to,not-interesting,default), the merged query'sRelationshipsToReturnrequests every one (plusrelated).WithInterestRelationships_WithJsonDeclaredRelationships_PreservesThemAlongsideInterestsSubViewRelationshipMergeTestsrelationships-to-returnentries survive the merge and coexist with the added interest relationship types (includingdefault).TryReadSubViewQueryRequest_ForViewDefinition_GeneratedQueryRetrievesEveryInterestTypeTryReadSubViewQueryRequestTestsdefault— is retrieved, not only those named in the view JSON.TryReadSubViewGetRequest_ForViewDefinition_GeneratedGetRetrievesEveryInterestTypeTryReadSubViewQueryRequestTestsGetRequestbuilt from a view definition is augmented so every interest relationship type (incl.default) is retrieved.TryReadGetEntityRequest_ForViewDefinition_GeneratedGetEntityRetrievesEveryInterestTypeTryReadSubViewQueryRequestTestsGetEntityRequestbuilt from a view definition retrieves every interest relationship type (incl.default) in addition to its JSON-declared relationships.InboxView_GeneratedQuery_RetrievesAllInterestRelationshipTypesIncludingDefaultInterestViewQueryTestsdefault), so all interest badges reflect current state on that view.