diff --git a/README.md b/README.md
index 2413537f..d4917b7c 100644
--- a/README.md
+++ b/README.md
@@ -71,6 +71,41 @@ docker compose -f docker-compose.release.yml exec api \
Open **http://localhost:13002** — or whatever `DONNA_WEB_HOST_PORT` you set in `.env` (`13002` is the default in `.env.example`) — and sign in with `admin@lq.ai` / `DonnaE2ePassw0rd!`.
+### Adding your colleagues
+
+The first-run admin is one account. To put other people on the deployment, create them from the
+admin account — each gets a one-time password and is forced to change it on first login:
+
+```bash
+TOKEN=... # an access token for the admin account
+curl -X POST http://localhost:18000/api/v1/admin/users \
+ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
+ -d '{"email":"colleague@yourfirm.example","display_name":"A Colleague","role":"member"}'
+```
+
+The response carries `initial_password` **once** — it is never stored or logged, so hand it over
+then. If it is lost, `POST /api/v1/admin/users/{id}/reset-password` issues a new one and signs the
+user out everywhere.
+
+Roles are `admin` (manage users and deployment settings), `member` (ordinary use), `viewer`, and
+`auditor` (read-only cross-user review of citation ledgers and receipts). Change one with
+`PATCH /api/v1/admin/users/{id}/role`.
+
+**A new matter reaches only its creator by default.** To share matters, add people to a matter's
+**People** section in the app, or flip the deployment default so new matters are readable
+firm-wide:
+
+```bash
+LQ_AI_MATTER_DEFAULT_SHARE_SCOPE=org # default: personal
+```
+
+Firm-wide grants _reading_; contributing to a matter always needs an explicit place on its roster.
+See [docs/GUIDE.md → People on a matter](docs/GUIDE.md#people-on-a-matter--working-it-together) for
+the practitioner view, and lq-ai's
+[`docs/security/matter-access-control.md`](https://github.com/LegalQuants/lq-ai/blob/main/docs/security/matter-access-control.md)
+for the operator view — including ethical screens, which override administrator rights, and the
+limits of what application-layer authorization guarantees.
+
Images are published from this repo to GHCR — `ghcr.io/legalquants/donna-web`, `donna-api`, and
`donna-gateway` (multi-arch: Intel/AMD + Apple Silicon). This still needs a filled `.env`; it removes
the _build_, not the _config_. For a fully free, no-cloud setup, leave the provider keys blank and run **Ollama on your host**, then set `OLLAMA_BASE_URL=http://host.docker.internal:11434` in `.env` (the pre-built stack does not bundle an Ollama container) — then pick a local model in the app's Models settings. Deploying beyond `localhost` still requires TLS in front of `donna-web` (see
diff --git a/docs/GUIDE.md b/docs/GUIDE.md
index ed6349c8..c892278c 100644
--- a/docs/GUIDE.md
+++ b/docs/GUIDE.md
@@ -225,6 +225,42 @@ so sensitive work can't silently route to an underpowered or lower-trust model.
matter and it opens already scoped: standing context applied, knowledge and skills available, tier
floor enforced.
+### People on a matter — working it together
+
+A matter has a **People** section: who is on it, and in what capacity. Colleagues you add see the
+matter's files, knowledge bases, standing context, and — importantly — **each other's chats in it**,
+each labelled with its author. That is the point of sharing a matter: see the work already done on
+it rather than repeat it.
+
+Four roles:
+
+- **Lead** — everything, plus deciding who else is on the matter and who can see it.
+- **Contributor** — read the matter and add to it: edit the standing context, attach documents,
+ start chats.
+- **Reader** — read it. Change nothing.
+- **Screened** — an ethical wall. See below.
+
+Above the roster, **who can see this matter** sets the ambient reach: _Just me_, _Named people only_,
+or _Everyone at the firm_. Firm-wide grants **reading** and nothing more — contributing still needs a
+place on the list, so the roster stays a truthful record of who actually worked the matter. That
+record is what you will want months later, when the question is who did what.
+
+**Screening someone off a matter.** Set their role to **Screened** and the matter disappears for
+them: not a permission error, just gone, along with every chat in it. A screen overrides everything
+else — firm-wide visibility, an explicit role, and **administrator rights**. An administrator who
+must see a screened matter has to lift the screen, which is recorded against their name. That is
+deliberate: a wall the administrator can walk through is not a wall, and in a small firm the
+administrator is usually also a practising lawyer. Screened people are listed separately from the
+working team, so the wall is visible rather than buried in a roster.
+
+Every change to the roster is written to the audit log — who was added, who changed a role, and
+specifically when a screen went up and when it came down.
+
+**One thing you cannot do:** post into a colleague's chat. You can read every thread in a shared
+matter; only its author can add to it — a matter lead included. Interleaving two lawyers' turns in
+one conversation would blur which of them directed which answer, and that is exactly the record
+that has to stay unambiguous. To act on a shared matter, start your own thread in it.
+
### Knowledge bases — grounding answers in your documents
A **knowledge base** is a named collection of documents Donna searches when answering — the mechanism
diff --git a/src/lib/matters/SharedChip.svelte b/src/lib/matters/SharedChip.svelte
new file mode 100644
index 00000000..730278f8
--- /dev/null
+++ b/src/lib/matters/SharedChip.svelte
@@ -0,0 +1,25 @@
+
+
+
+
+ {text}
+
diff --git a/src/lib/matters/SharedChip.svelte.test.ts b/src/lib/matters/SharedChip.svelte.test.ts
new file mode 100644
index 00000000..b251fe83
--- /dev/null
+++ b/src/lib/matters/SharedChip.svelte.test.ts
@@ -0,0 +1,18 @@
+///
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import SharedChip from './SharedChip.svelte';
+
+describe('SharedChip', () => {
+ it('distinguishes a matter somebody put you on from one nobody had to', () => {
+ render(SharedChip, { props: { basis: 'member' } });
+ expect(screen.getByText('Shared')).toBeInTheDocument();
+ expect(screen.getByLabelText(/added to this matter by someone else/i)).toBeInTheDocument();
+ });
+
+ it('labels firm-wide readability as such', () => {
+ render(SharedChip, { props: { basis: 'org' } });
+ expect(screen.getByText('Firm-wide')).toBeInTheDocument();
+ expect(screen.getByLabelText(/readable by everyone at the firm/i)).toBeInTheDocument();
+ });
+});
diff --git a/src/lib/matters/sections/TeamSection.svelte b/src/lib/matters/sections/TeamSection.svelte
new file mode 100644
index 00000000..82273247
--- /dev/null
+++ b/src/lib/matters/sections/TeamSection.svelte
@@ -0,0 +1,243 @@
+
+
+
+
+ This matter is marked privileged and readable firm-wide. If someone must be walled off it,
+ screen them below — a screen overrides firm-wide access.
+
+ {label(m)}
+
+ Cannot see this matter, whatever else grants access.
+
+
+ {#if canManage}
+
+ {/if}
+
+ {/each}
+
+ {/if}
+
diff --git a/src/lib/matters/sections/TeamSection.svelte.test.ts b/src/lib/matters/sections/TeamSection.svelte.test.ts
new file mode 100644
index 00000000..af66f87a
--- /dev/null
+++ b/src/lib/matters/sections/TeamSection.svelte.test.ts
@@ -0,0 +1,129 @@
+///
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import userEvent from '@testing-library/user-event';
+import TeamSection from './TeamSection.svelte';
+import type { DirectoryEntry, MatterMember, MatterRole } from '$lib/matters/types';
+
+vi.mock('$app/forms', () => ({ enhance: () => ({}) }));
+
+const member = (over: Partial & { user_id: string }): MatterMember => ({
+ email: `${over.user_id}@example.com`,
+ display_name: null,
+ role: 'contributor' as MatterRole,
+ is_owner: false,
+ added_by_user_id: 'u1',
+ created_at: '2026-08-20T00:00:00Z',
+ ...over
+});
+
+const owner = member({ user_id: 'u1', display_name: 'Dana Okafor', role: 'lead', is_owner: true });
+const ana = member({ user_id: 'u2', display_name: 'Ana', role: 'contributor' });
+const luis = member({ user_id: 'u3', display_name: 'Luis', role: 'blocked' });
+
+const dir: DirectoryEntry[] = [
+ { id: 'u1', email: 'u1@example.com', display_name: 'Dana Okafor' },
+ { id: 'u4', email: 'u4@example.com', display_name: 'Marta' }
+];
+
+const props = (over: Record = {}) => ({
+ members: [owner, ana],
+ directory: dir,
+ shareScope: 'personal' as const,
+ canManage: true,
+ ...over
+});
+
+describe('TeamSection', () => {
+ it('counts only the working team, not screened people', () => {
+ render(TeamSection, { props: props({ members: [owner, ana, luis] }) });
+ expect(screen.getByRole('heading', { name: /people · 2/i })).toBeInTheDocument();
+ });
+
+ it('marks the owner and does not offer to change or remove them', () => {
+ render(TeamSection, { props: props() });
+ expect(screen.getByText(/owner · lead/i)).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /remove dana okafor/i })).not.toBeInTheDocument();
+ expect(screen.queryByLabelText(/role for dana okafor/i)).not.toBeInTheDocument();
+ });
+
+ it('gives a lead a role picker and a remove control per other member', () => {
+ render(TeamSection, { props: props() });
+ expect(screen.getByLabelText(/role for ana/i)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /remove ana/i })).toBeInTheDocument();
+ });
+
+ it('shows a non-lead the roster read-only', () => {
+ render(TeamSection, { props: props({ canManage: false }) });
+ expect(screen.getByText('Contributor')).toBeInTheDocument();
+ expect(screen.queryByLabelText(/role for ana/i)).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /add someone/i })).not.toBeInTheDocument();
+ });
+
+ it('lists screened people apart from the team, with a Lift screen control', () => {
+ render(TeamSection, { props: props({ members: [owner, ana, luis] }) });
+ expect(screen.getByRole('heading', { name: /screened · 1/i })).toBeInTheDocument();
+ expect(screen.getByText(/cannot see this matter/i)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /lift screen/i })).toBeInTheDocument();
+ });
+
+ it('calls a screened person Screened, never blocked', () => {
+ render(TeamSection, { props: props({ members: [owner, ana, luis] }) });
+ expect(screen.queryByText(/\bblocked\b/i)).not.toBeInTheDocument();
+ });
+
+ it('offers the share scope as a lead-editable control', () => {
+ render(TeamSection, { props: props({ shareScope: 'org' }) });
+ const select = screen.getByLabelText(/who can see this matter/i) as HTMLSelectElement;
+ expect(select.value).toBe('org');
+ expect(screen.getByText(/everyone at the firm can read it/i)).toBeInTheDocument();
+ });
+
+ it('shows a non-lead the share scope as text, not a control', () => {
+ render(TeamSection, { props: props({ shareScope: 'org', canManage: false }) });
+ expect(screen.queryByLabelText(/who can see this matter/i)).not.toBeInTheDocument();
+ expect(screen.getByText('Everyone at the firm')).toBeInTheDocument();
+ });
+
+ it('points a lead at screening when a privileged matter is firm-wide', () => {
+ render(TeamSection, { props: props({ shareScope: 'org', privileged: true }) });
+ expect(screen.getByText(/a screen overrides firm-wide access/i)).toBeInTheDocument();
+ });
+
+ it('keeps people already on the roster out of the add picker', async () => {
+ const user = userEvent.setup();
+ render(TeamSection, { props: props({ members: [owner, ana, luis] }) });
+ await user.click(screen.getByRole('button', { name: /add someone/i }));
+
+ const options = Array.from((screen.getByLabelText('Person') as HTMLSelectElement).options).map(
+ (o) => o.value
+ );
+ // u1 is already the owner; u2 and u3 are on the roster (u3 screened).
+ expect(options).not.toContain('u1');
+ expect(options).not.toContain('u3');
+ expect(options).toContain('u4');
+ });
+
+ it('says so plainly when there is nobody left to add', () => {
+ render(TeamSection, { props: props({ directory: [dir[0]] }) });
+ expect(screen.getByText(/everyone in the firm is already on this matter/i)).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /add someone/i })).not.toBeInTheDocument();
+ });
+
+ it('explains what the chosen role permits before you commit to it', async () => {
+ const user = userEvent.setup();
+ render(TeamSection, { props: props() });
+ await user.click(screen.getByRole('button', { name: /add someone/i }));
+ expect(screen.getByText(/can read the matter and add to it/i)).toBeInTheDocument();
+ });
+
+ it('surfaces an action error', () => {
+ render(TeamSection, { props: props({ error: 'Only a matter lead can change who is on it.' }) });
+ expect(screen.getByText(/only a matter lead/i)).toBeInTheDocument();
+ });
+
+ it('falls back to the email when someone has no display name', () => {
+ render(TeamSection, { props: props({ members: [owner, member({ user_id: 'u9' })] }) });
+ expect(screen.getByText('u9@example.com')).toBeInTheDocument();
+ });
+});
diff --git a/src/lib/matters/types.ts b/src/lib/matters/types.ts
index f7657c15..83bc8c77 100644
--- a/src/lib/matters/types.ts
+++ b/src/lib/matters/types.ts
@@ -13,7 +13,112 @@ export interface MatterHeaderInfo {
minimumTier: 1 | 2 | 3 | 4 | 5 | null;
}
-/** Drop the per-user sandbox project; the list/picker only show real matters. */
-export function activeMatters(projects: Matter[]): Matter[] {
+/** Drop the per-user sandbox project; the list/picker only show real matters.
+ *
+ * Generic over the matter shape so callers that read the sharing fields
+ * (see `SharedMatter`) do not lose them to a widening return type.
+ */
+export function activeMatters(projects: T[]): T[] {
return projects.filter((p) => !p.is_sandbox);
}
+
+/** Roles on a matter's roster (`project_members.role`).
+ *
+ * `blocked` is a *negative* grant — an ethical screen. The backend resolver
+ * evaluates it before every allow, so it overrides firm-wide scope and
+ * operator-admin alike. The UI calls it "Screened", which is the term a
+ * lawyer will recognise; "blocked" is only ever the wire value.
+ */
+export type MatterRole = 'lead' | 'contributor' | 'reader' | 'blocked';
+
+/** Ambient grant over a matter (`projects.share_scope`). */
+export type ShareScope = 'personal' | 'members' | 'org';
+
+/** What the caller may do on a matter (`ProjectResponse.caller_access`). */
+export type MatterAccess = 'read' | 'write' | 'lead';
+
+/** A colleague, as `GET /api/v1/users/directory` returns them. */
+export interface DirectoryEntry {
+ id: string;
+ email: string;
+ display_name: string | null;
+}
+
+/** Display name if there is one, otherwise the email. */
+export function personLabel(p: { display_name?: string | null; email: string }): string {
+ return p.display_name?.trim() || p.email;
+}
+
+/** One row of a matter's roster. */
+export interface MatterMember {
+ user_id: string;
+ email: string;
+ display_name: string | null;
+ role: MatterRole;
+ is_owner: boolean;
+ added_by_user_id: string;
+ created_at: string;
+}
+
+/** Matter fields added by the membership work.
+ *
+ * Declared here rather than read from `backend.d.ts` because those types are
+ * generated from the pinned `vendor/lq-ai` OpenAPI sketch; they fold in at
+ * the next pin bump. Everything is optional so the UI degrades cleanly
+ * against an API that predates them.
+ */
+export interface MatterSharing {
+ share_scope?: ShareScope;
+ caller_access?: MatterAccess;
+ caller_access_basis?: 'owner' | 'member' | 'org' | 'no_grant';
+}
+
+export type SharedMatter = Matter & MatterSharing;
+
+/** True when the caller may manage the roster and the share scope. */
+export function canManageMatter(matter: SharedMatter): boolean {
+ return matter.caller_access === 'lead';
+}
+
+/** True when the caller may edit the matter's content. */
+export function canEditMatter(matter: SharedMatter): boolean {
+ return matter.caller_access === 'lead' || matter.caller_access === 'write';
+}
+
+/** True when the matter reached the caller through someone else's sharing.
+ *
+ * Drives the "Shared" chip on the matter list. Keyed on the basis rather
+ * than `owner_id` so the answer stays right for a lead who is not the owner.
+ */
+export function isSharedWithCaller(matter: SharedMatter): boolean {
+ return matter.caller_access_basis === 'member' || matter.caller_access_basis === 'org';
+}
+
+/** Human labels for roster roles. */
+export const MATTER_ROLE_LABELS: Record = {
+ lead: 'Lead',
+ contributor: 'Contributor',
+ reader: 'Reader',
+ blocked: 'Screened'
+};
+
+/** What each role actually permits, shown next to the picker so staffing a
+ * matter does not require reading the API docs. */
+export const MATTER_ROLE_HINTS: Record = {
+ lead: 'Full control, including who else is on the matter.',
+ contributor: 'Can read the matter and add to it.',
+ reader: 'Can read the matter. Cannot change it.',
+ blocked: 'Screened off. Cannot see the matter at all, whatever else grants access.'
+};
+
+export const SHARE_SCOPE_LABELS: Record = {
+ personal: 'Just me',
+ members: 'Named people only',
+ org: 'Everyone at the firm'
+};
+
+export const SHARE_SCOPE_HINTS: Record = {
+ personal: 'Only you and anyone you add below.',
+ members: 'Only the people listed below.',
+ org: 'Everyone at the firm can read it. Contributing still needs a place on the list.'
+};
diff --git a/src/routes/(app)/matters/+page.server.ts b/src/routes/(app)/matters/+page.server.ts
index e190fa05..e2a79826 100644
--- a/src/routes/(app)/matters/+page.server.ts
+++ b/src/routes/(app)/matters/+page.server.ts
@@ -1,13 +1,13 @@
import { error, fail, redirect, type Actions } from '@sveltejs/kit';
import { lqFetch } from '$lib/server/lqClient';
-import { activeMatters, type Matter } from '$lib/matters/types';
+import { activeMatters, type Matter, type SharedMatter } from '$lib/matters/types';
import { parsePrivilegeFields } from '$lib/matters/parseFormFields';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
const res = await lqFetch(event, '/api/v1/projects');
if (!res.ok) throw error(502, 'Could not load matters.');
- return { matters: activeMatters((await res.json()) as Matter[]) };
+ return { matters: activeMatters((await res.json()) as SharedMatter[]) };
};
export const actions: Actions = {
diff --git a/src/routes/(app)/matters/+page.svelte b/src/routes/(app)/matters/+page.svelte
index adc9264c..b3af1ca2 100644
--- a/src/routes/(app)/matters/+page.svelte
+++ b/src/routes/(app)/matters/+page.svelte
@@ -2,6 +2,8 @@
import { Plus, FolderKanban } from '@lucide/svelte';
import MatterForm from '$lib/matters/MatterForm.svelte';
import PrivilegedChip from '$lib/matters/PrivilegedChip.svelte';
+ import SharedChip from '$lib/matters/SharedChip.svelte';
+ import { isSharedWithCaller } from '$lib/matters/types';
let { data, form } = $props();
let showCreate = $state(false);
@@ -47,6 +49,7 @@