Feature Specification: Record Visibility
Feature ID: record-visibility
Status: Draft (EW-P1-3)
Created: 2026-10-15
Last updated: 2026-10-16
Owner: Product (Ruslan)
Problem. Tasks, goals, missions, ideas and agents are private to their owner: every list filters on
userId = caller(task.repository.ts,ownership-scope.ts), so two members of one organization never see each other's tasks, and a user assigned to a task cannot even list it. Only Teams are org-wide. An organization-level integration (Ever Gauzy) has no shared view to sync.Owner decisions. D6: keep today's per-user ownership and add visibility through the org, teams, works and participants. D11 (2026-10-15): a record attached to a Team is team-scoped; a record with no team is organization-scoped — for new and existing org records, applied automatically by the migration, with no opt-in and no "share" action. Owners can still mark a record
private.Hard rule (additive only): the owner keeps every right they have today;
Minestays the default tab.
1. Columns
visibility varchar(16) NOT NULL DEFAULT 'private'(private|team|organization) andvisibilityExplicit boolean NOT NULL DEFAULT falseontasks,goals,missions,work_proposalsandagents.team_resources.resourceTypegainsgoal, so goals can sit on a team like every other resource (goal form shows and edits its teams).
2. Backfill (same migration)
- Every existing org-scope row (
organizationId IS NOT NULL):teamif it sits on a team (teamId,team_resources, or for agents a team membership), otherwiseorganization;visibilityExplicit = false. - Personal-scope rows (
organizationId IS NULL) stayprivate. - Roster backfill: insert an
organization_membersrow for every active user whosetenantIdmatches the org's tenant. They already have tenant-wide access (a user joins a tenant only by accepting an organization invitation, andorganization-membership.service.tsauthorizes onuser.tenantId === org.tenantId), so access does not change. The roster only records that existing membership; it grants no permission by itself, and it is not what authorizes a write — an integration's act-as user still passes that user's own permission checks for every operation (integration-keys§2).- As implemented (migration
1795070000000-AddRecordVisibility): the rows go to every active, non-anonymous user of the tenant who holds no roster row in that tenant yet — the members who joined beforeorganization_memberswas written (it is empty in production) — one row per organization of the tenant, since nothing records which one they were invited to. A user who already holds a row joined through the roster-era invitation flow, which records the organization they were invited to; adding sibling-organization rows for them would change whatremoveMemberrevokes, so they are left alone. The tenant owner gets no row: a member by construction (listMemberssynthesizes the row, seat counting already counts them), and the access policy treats the owner as a member of every organization of their tenant.joinedAt= the later of the organization's and the account's creation.down()keeps these rows (they state true membership and cannot be told apart from later invitation rows).
- As implemented (migration
- Insert-time default (§5) ships with the columns: an entity
@BeforeInsertlistener on the five entities, plusScopeStampingSubscriberafter it stamps the scope (the entity listener runs first), setteam/organizationon new org rows, so no org row is createdprivatebetween this migration and the read policy.
3. Who can read an org-scoped record
private: the owner (today's rule) and the participants — task assignees, approvers, reviewers and watchers.team: also the members of the record's teams (teamIdandteam_resources) and of its Work (work_members).organization: also every member of the org (roster).
4. Who can write
- Writes stay with the owner and the existing role rules.
- Participants may comment and transition, as the existing assignee rules allow.
- Connected-group and org readers may comment.
5. Defaults and automatic scope
- New records in an org scope:
teamwhen created with a team, otherwiseorganization. Personal-scope records stayprivate. - While
visibilityExplicit = false: adding the first team narrowsorganization→team; removing the last team widensteam→organization. - Choosing a visibility by hand sets
visibilityExplicit = trueand stops automatic changes.privateis only ever set by hand. - Integration writes may set
teamororganization(422 domain_ruleforprivate) and sendteamIds[], stored asteamId(first) plusteam_resources(rest).
As implemented (RecordVisibilityService, packages/agent/src/record-visibility/):
setVisibility(aggregate, id, choice)—private/team/organizationby hand (visibilityExplicit = true) orautomatic(clears the flag and re-applies D11). A personal record takesprivate/automaticonly (400).recomputeAutomatic(aggregate, id)runs after every Team change:team_resourcesattach / detach (TeamResourcesService), an agent joining or leaving a Team (TeamsService.addMember/removeMember), a task'steamIdset or cleared (TasksService.update), and a Team deleted (TeamsService.removecollects its records first, sinceteam_resources/team_memberscascade andtasks.teamIdis SET NULL). No-op for a hand-picked value and for a personal record; never fails the Team write that triggered it.setTeams(aggregate, id, teamIds)— the record's Teams as one set (integrationteamIds[], the goal form): for a task the first isteamIdand the restteam_resources; for the others every id is ateam_resourcesrow. Every Team must belong to the record's organization (404). D11 then re-applies.assertIntegrationVisibility—private→422{ error: { code: 'domain_rule' } }.- API (the forms' control):
GET /api/record-visibility/:aggregate/:id(anyone who may read: state +canEdit),PUT /api/record-visibility/:aggregate/:id{ visibility }andPUT …/teams{ teamIds }(the owner; 404 otherwise; an integration principal may not chooseprivate).
6. Organization roster
organization_members becomes the source of truth for "member of the org": kept in sync on invite accept and on
removal (removal emits organization.member.removed).
7. UI
- A visibility control (lock / team / org icon) on the task, goal, mission, idea and agent forms. It shows "Automatic" until the user picks a value.
- List tabs Mine | Shared with me | Organization on
/tasks,/goals,/missions,/ideasand/agents;Minestays the default.
As implemented (web): components/record-visibility/ — RecordViewTabs (the tab lives in ?tab= on the web URL,
because /tasks already uses ?view= for board / list; sent to the API as view; every other filter is kept and paging
resets), VisibilityControl (lock / team / organization icon; "Automatic (…)" until a value is picked; only the owner
gets the picker), RecordTeamsEditor (the goal's Teams) and RecordVisibilityPanel (both, the control re-reads after a
Teams save). Other locales fall back to en.json through the request-time deep merge.
8. Implementation shape
One RecordAccessPolicy service plus a repository helper visibleTo(principal). Every list and get path goes through
it, replacing the scattered userId = caller filters; each replaced filter becomes a test case. The integration
principal reads every row in its pinned orgs with visibility team or organization.
As implemented (packages/agent/src/record-visibility/):
visibleToPredicate()(visible-to.ts) is the one SQL predicate for all five aggregates. A user reads a row when they own it; take part in it (tasks: user assignee, approver, reviewer, watcher — any visibility); reach it through a Team (tasks.teamId,team_resourcesof the type, an agent'steam_members) or a Work they own or belong to (tasks.workId,agents.workId,mission_works) when it isteam/organization; or are an organization member (roster row, or the tenant owner) when it isorganization. Personal scope stays owner-only. The integration principal readsteam/organizationrows of its pinned organizations.RecordAccessPolicyresolves the caller's roster, Teams and Works once per request (contextFor) and exposesisOrganizationMember(orgId, userId)(the authoritative roster for act-as, EW-P1-2) andisTaskParticipant.- Views:
?view=mine|shared|organizationonGET /api/tasks(and the board),/api/me/goals,/api/me/missions,/api/me/work-proposalsand/api/agents.mine(default, also for unknown values) is the unchanged owner query;shared= rows the caller does not own but takes part in or reaches through a Team / Work;organization= every readable row of the active organization, own rows included. - Get by id: a new
getReadable(tasks, goals, missions, ideas, agents) answers the owner exactly as before, then falls back tovisibleTo. Only theGET /:idroutes use it: every write route keeps its owner-only gate (getOne/getForUser/requireOwned), several of which other routes use as an ownership check. - Writes (§4): a task's user assignees, approvers and reviewers may transition it (watchers and other readers get
403); anyone who can read a task may list and post comments, and edit their own; a non-owner's
@agentmention is kept on the message but never dispatches a run (no spend, no agent work on somebody else's task).
9. Events
A change of visibility records <aggregate>.visibility_changed (domain-events). A change
to private carries only {id, visibility:'private'} so consumers stop syncing without seeing the content.
As implemented: recorded through the domain-event recorder (EW-P1-1) in the same transaction as the change, with
the aggregate version bumped: task.visibility_changed and agent.visibility_changed (P1, versioned aggregates).
To team / organization: { id, organizationId, visibility, visibilityExplicit, version, teamIds } (valid against
visibility-changed.schema.json; the full integration DTO joins with the B2 snapshot builders). Goals, missions and ideas
record once they carry a version column (their P2 recording points) — the hook is marked in recordChange.
10. Acceptance criteria
- Visibility matrix: owner, participant, team member, work member, org member, outsider, integration principal ×
private/team/organization. - Automatic team ↔ organization changes while
visibilityExplicit = false, none after a manual choice. - Existing org rows are
team/organizationafter the migration; personal rows stayprivate;downreverts. -
Minelists exactly what it lists today.