Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions specs/008-reporting-managers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# 008: Reporting managers can approve their reports' time off

| | |
| --- | --- |
| Status | Approved for local build (maintainer approval still required to upstream) |
| Size | Cross-cutting (migration, API, web app, permissions) |
| Figma | None — no access. UI validated by local deploy. Blocks an upstream PR until frames exist |
| PR | — |

## Problem

Time off under the `approval` policy can only be decided by workspace
admins and the standup's managers (`schedules.manage`). The person who
should sign off a day away is often a line manager who isn't a manager of
that team — so to let them approve, you have to make them a full manager of
the standup, handing them its whole configuration. There is also no record
of who a person reports to on a given team. We want a per-team reporting
manager, chosen from people who already manage somewhere, who can approve
their reports' days off without being made a manager of this standup.

## User stories

- As an admin or standup manager, I want to set who a person reports to on
this team, so the right person signs off their days off.
- As a reporting manager, I want my reports' off-day requests to reach me
and let me approve or decline them, in Chat or the web app, without being
a manager of the standup.
- As someone on two teams, I want the right manager on each team to be able
to approve my leave, since I answer to different people per team.

## Acceptance criteria

Assigning a reporting manager (per team)

1. Given someone with `people.manage` on a standup, when they set a
participant's reporting manager on that standup's team view, then it is
saved against that person on that standup and shown on the row.
2. The reporting manager can only be someone who is **assigned as a manager**
on at least one standup. The picker offers only those people; choosing
anyone else is refused: "A reporting manager must already be a manager on
a standup."
3. A person cannot be their own reporting manager on a standup: the save is
refused with "Someone can't report to themselves."
4. Clearing the field removes it; that person then has no reporting manager
on that standup and the approver set reverts to the team's managers and
admins.
5. The reporting manager is per standup: the same person on another standup
can have a different reporting manager, set independently.

Approving as a reporting manager

6. Given a standup on the `approval` policy, when a person requests a day
**off**, then the pending-request card is DMed to that standup's managers
and admins **and** to that person's reporting manager on that standup, if
set and not already among them.
7. A **working** request (back-to-work on an off day) is not sent to the
reporting manager; it goes to the standup's managers and admins only, as
today.
8. When any eligible approver — a manager/admin of an affected standup **or**
that person's reporting manager on an affected standup — approves or
declines an off-day request, the decision applies exactly as a manager's
does today across all the person's standups: the run is updated, the
person is told, and the webhook fires. **The first decision wins** (others
see "already decided").
9. A reporting manager sees their reports' pending off-day requests on Team ›
Requests for the standups they are the reporting manager on, even if they
do not manage those standups.
10. A reporting manager who is also the requester cannot decide their own
request: "Your own requests go to your managers." (Unchanged behaviour.)
11. The reporting manager relationship only grants approval under the
`approval` policy. It creates no new approval path on `self` or
`managers` standups.

No regression to the downstream flow

12. An approval made by a reporting manager marks the person away for that
run, and their Google Calendar out-of-office, Workspace status and the
"off" chart update exactly as an admin's approval does today — the same
`applyToRuns` → `LeavePresence` path, no new sync code.

## Non-goals

- **Half-day or partial-day leave.** Dropped; leave stays whole-day.
- A reporting manager **setting or changing** a report's schedule directly.
They approve requests; marking someone away stays with managers/admins.
- A reporting manager deciding **working** requests (criterion 7).
- **Each-team-must-approve.** One approval settles the request for all of a
person's teams, as today. No partial-approval state.
- Letting anyone who is not already a manager be a reporting manager.
- Deriving the reporting manager from the Google Directory org chart or SCIM.
- A workspace-wide "one boss" per person, approval chains, or delegates.
- MCP changes.

## Design impact

| Area | Change |
| --- | --- |
| Web app | On each standup's team view, a **Reporting manager** control on every participant row — a picker listing the workspace's managers, with a clear option, behind `people.manage`. Team › Requests also lists off-day requests where the caller is the reporting manager |
| Chat cards and dialogs | None new. The existing time-off request card (`sendTimeOffRequest`) is additionally DMed to the reporting manager, for off-day requests only |
| API and OpenAPI | `PATCH /standups/:id/participants/:userName` gains a `reportingManager` field (`users/…` or `null`); the participant payload returns it. `GET /requests` widens its result to include off-day requests where the caller is a reporting manager; `POST /requests/:id/{approve,decline}` accepts a reporting manager as decider |
| MCP | None |
| Database | New migration, both dialects: two columns on `participants` — `reporting_manager` (the manager's `users/…`, nullable) and `reporting_manager_name` (their display name) |
| Permissions | None new. Assigning uses existing `people.manage` on the standup; deciding as a reporting manager is granted by the relationship, alongside `schedules.manage` |
| Docs | `roles.md` and/or `schedules.md` (who can approve), `web-app.md` (team row), `api.md` (participant field + widened requests) |
| Website | None |
| Google APIs and scopes | None |

How it works:

- The reporting manager is a per-participant attribute, so it lives as two
columns on `participants` — the table already keyed by `(standup_id,
user_name)`. Same person on two standups → two independent values. This
mirrors how `standup_admins` and the `mandatory` flag already work.
- Candidates are people assigned as a manager on at least one standup
(`standup_admins`), gathered across the tenant. The web picker shows that
set; the API validates the chosen `reportingManager` is in it.
- Assignment reuses the existing participant route
(`PATCH /standups/:id/participants/:userName`), which already needs
`people.manage` — no new route, no new permission.
- `ScheduleService.setOverride`, when an **off** request goes `pending`,
already DMs each governed standup's admins; it additionally reads that
person's `reporting_manager` on each such standup and DMs them the same
card, de-duplicated against admins already told. A `working` pending
request skips the reporting manager.
- `ScheduleService.decide` gains one authority branch: alongside
`actor.admin` and `schedules.manage`, the actor may decide an off-day
request if they are the requester's reporting manager on any of the
requester's standups. The self-request guard (criterion 10) is already
there and still applies, and "first decision wins" is the existing
behaviour — one approval clears the person's leave across every team.
- `GET /requests` builds its `names` list from the standups the caller
manages; it now also adds the people who report to the caller on any
standup, so a reporting manager who manages no standup still sees their
reports' off-day requests.
- Approval runs the existing path unchanged: `decide` → `applyToRuns` →
the next `LeavePresence.sweep` writes the calendar and status, and the run
shows the person off. Criterion 12 is a no-new-code assertion, covered by
a test that a reporting-manager approval leaves the same run/leave state
as an admin approval.
- Failure cases: a reporting manager who stops being a manager everywhere
keeps their existing assignments working (they are still a valid decider
for those), but can no longer be picked afresh. One removed from the
workspace leaves a dangling `reporting_manager`; `decide` finds no match
and the normal approvers still apply.

## Open questions

None. Decided for the local build on 25 Oct 2026:

- The reporting manager is **per team** (per standup), stored on the
participant row.
- Candidates are **existing roster members already assigned as managers**.
- The reporting manager approves **off-day requests only**, not working
requests.
- **One approval** settles an off-day request across all of a person's
teams (first decision wins), as today.
- No Figma access: the UI is **validated by local deploy**. This is a
deliberate deviation from the Definition of Done and must be resolved with
frames before any upstream PR.
23 changes: 16 additions & 7 deletions src/api/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,24 +230,33 @@ export function registerScheduleRoutes(api: Router, ctx: ApiContext): void {
api.get('/requests', async (req, res) => {
if (!schedule(res)) return;
const p = principalOf(req);
if (!canSomewhere(p.access, 'schedules.manage')) {
const me = p.user?.userName ?? null;
const reports = new Set(me ? await repo.listReportsOf(me) : []);
if (!canSomewhere(p.access, 'schedules.manage') && reports.size === 0) {
apiError(res, 403, 'forbidden', 'Only admins and managers see requests.');
return;
}
let names: string[] | undefined;
if (!allowed(p, 'schedules.manage')) {
names = [];
for (const id of standupsWith(p.access, 'schedules.manage')) for (const x of await repo.listParticipants(id)) names.push(x.userName);
const workspaceWide = allowed(p, 'schedules.manage');
const managed = new Set<string>();
if (!workspaceWide) {
for (const id of standupsWith(p.access, 'schedules.manage')) for (const x of await repo.listParticipants(id)) managed.add(x.userName);
}
res.json({ requests: (await repo.listPendingOverrides(names)).map(overrideView) });
const names = workspaceWide ? undefined : [...new Set([...managed, ...reports])];
const requests = (await repo.listPendingOverrides(names))
// A reporting manager who doesn't manage the person only sees their days off.
.filter((o) => workspaceWide || managed.has(o.userName) || !o.working)
.map(overrideView);
res.json({ requests });
});

for (const decision of ['approve', 'decline'] as const) {
api.post(`/requests/:id/${decision}`, async (req, res) => {
const svc = schedule(res);
if (!svc) return;
const p = principalOf(req);
if (!canSomewhere(p.access, 'schedules.manage')) {
const me = p.user?.userName ?? null;
const hasReports = me ? (await repo.listReportsOf(me)).length > 0 : false;
if (!canSomewhere(p.access, 'schedules.manage') && !hasReports) {
apiError(res, 403, 'forbidden', 'Only admins and managers decide requests.');
return;
}
Expand Down
1 change: 1 addition & 0 deletions src/api/spec/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export const ROUTES: RouteSpec[] = [
},
{ method: 'get', path: '/standups/:id/webhook/secret', summary: 'The key this standup’s webhooks are signed with', tag: 'Standups', access: 'manager', permission: 'standups.configure', params: standupId, responses: { 200: S.WebhookSecret }, errors: [403, 404, 503] },
{ method: 'get', path: '/standups/:id/members', summary: 'Members of the standup’s Chat space, as roster candidates', tag: 'Roster', access: 'manager', permission: 'people.manage', params: standupId, responses: { 200: S.SpaceMembers }, errors: [404, 502] },
{ method: 'get', path: '/standups/:id/manager-candidates', summary: 'People who can be set as a reporting manager', tag: 'Roster', access: 'manager', permission: 'people.manage', params: standupId, responses: { 200: S.ManagerCandidates }, errors: [404] },
{ method: 'post', path: '/standups/:id/roster/sync', summary: 'Sync the roster with its Google Group, or with its Chat space, now', tag: 'Roster', access: 'manager', permission: 'people.manage', params: standupId, responses: { 200: S.RosterSynced }, errors: [404, 502, 503] },
{ method: 'post', path: '/standups/:id/participants', summary: 'Add someone to the roster', tag: 'Roster', access: 'manager', permission: 'people.manage', params: standupId, body: S.AddParticipant, responses: { 201: S.AddedParticipant }, errors: [400, 404] },
{
Expand Down
30 changes: 28 additions & 2 deletions src/api/spec/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ export const StandupDetail = named(
workingDays: z.string().nullable(),
otherStandups: count,
source: z.enum(['manual', 'group']),
reportingManager: z.string().nullable().describe('Who approves this person’s days off on this standup, besides its managers; null when none is set.'),
reportingManagerName: z.string().nullable(),
}),
),
admins: z.array(Person),
Expand Down Expand Up @@ -225,7 +227,21 @@ export const AddedParticipant = named(
);
export const UpdatedParticipant = named(
'UpdatedParticipant',
z.strictObject({ userName: z.string(), displayName: z.string(), mandatory: z.boolean(), onVacation: z.boolean(), admin: z.boolean() }),
z.strictObject({
userName: z.string(),
displayName: z.string(),
mandatory: z.boolean(),
onVacation: z.boolean(),
admin: z.boolean(),
reportingManager: z.string().nullable(),
reportingManagerName: z.string().nullable(),
}),
);

export const ManagerCandidates = named(
'ManagerCandidates',
z.strictObject({ managers: z.array(Person) }),
'People assigned as a manager on some standup — the pool a reporting manager is picked from.',
);

// --- blockers and people
Expand Down Expand Up @@ -540,7 +556,17 @@ export const CreateStandup = named(
);

export const AddParticipant = named('AddParticipant', z.strictObject({ userName: z.string().startsWith('users/'), displayName: text.min(1), mandatory: z.boolean().optional() }));
export const UpdateParticipant = named('UpdateParticipant', z.strictObject({ mandatory: z.boolean(), onVacation: z.boolean(), admin: z.boolean() }).partial());
export const UpdateParticipant = named(
'UpdateParticipant',
z
.strictObject({
mandatory: z.boolean(),
onVacation: z.boolean(),
admin: z.boolean(),
reportingManager: z.string().startsWith('users/').nullable().describe('Who approves this person’s days off; must already be a manager on a standup. null clears it.'),
})
.partial(),
);
export const BlockerUpdateBody = named('BlockerUpdateBody', z.strictObject({ text: text.min(1) }));
export const SkipBody = named('SkipBody', z.strictObject({ standupId: id, reason: z.string().min(1).max(LIMITS.skipNoteMax) }), 'The reason is one line, posted in the day’s thread.');
export const MyPatch = named('MyPatch', z.strictObject({ timezone: z.string().nullable(), onVacation: z.boolean() }).partial(), 'A null or empty timezone follows the standups’ zones again.');
Expand Down
29 changes: 29 additions & 0 deletions src/api/standups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,13 @@ export function registerStandupRoutes(api: Router, ctx: ApiContext): void {
res.json({ secret: ctx.webhookSecret(standup.id) });
});

// The pool a reporting manager is picked from: anyone assigned as a manager on some standup.
api.get('/standups/:id/manager-candidates', async (req, res) => {
const standup = await loadStandup(ctx, req, res, { need: 'people.manage' });
if (!standup) return;
res.json({ managers: await repo.listManagerCandidates(standup.tenantId) });
});

api.post('/standups/:id/participants', async (req, res) => {
const standup = await loadStandup(ctx, req, res, { need: 'people.manage' });
if (!standup) return;
Expand Down Expand Up @@ -216,6 +223,26 @@ export function registerStandupRoutes(api: Router, ctx: ApiContext): void {
return;
}
}
if (body.reportingManager !== undefined) {
const rm = body.reportingManager;
if (rm !== null && typeof rm !== 'string') {
apiError(res, 400, 'invalid', 'reportingManager must be a userName or null.', 'reportingManager');
return;
}
if (rm === null) {
await repo.setReportingManager(standup.id, userName, null);
} else if (rm === userName) {
apiError(res, 400, 'invalid', "Someone can't report to themselves.", 'reportingManager');
return;
} else {
const candidate = (await repo.listManagerCandidates(standup.tenantId)).find((c) => c.userName === rm);
if (!candidate) {
apiError(res, 400, 'invalid', 'A reporting manager must already be a manager on a standup.', 'reportingManager');
return;
}
await repo.setReportingManager(standup.id, userName, candidate);
}
}
if (body.mandatory !== undefined) await repo.setParticipantMandatory(standup.id, userName, body.mandatory);
if (body.onVacation !== undefined) await repo.setParticipantVacation(standup.id, userName, body.onVacation);
if (body.admin === true) await repo.addAdmin(standup.id, userName, participant.displayName);
Expand All @@ -234,6 +261,8 @@ export function registerStandupRoutes(api: Router, ctx: ApiContext): void {
mandatory: fresh.mandatory,
onVacation: fresh.onVacation,
admin: await repo.isAdmin(standup.id, userName),
reportingManager: fresh.reportingManager,
reportingManagerName: fresh.reportingManagerName,
});
});

Expand Down
2 changes: 2 additions & 0 deletions src/api/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ export async function standupDetailView(repo: Repo, standup: Standup, now: DateT
onVacation: x.onVacation,
workingDays: x.workingDays,
source: x.source,
reportingManager: x.reportingManager,
reportingManagerName: x.reportingManagerName,
// A personal week is the person's, so the console warns when changing it here reaches other standups too.
otherStandups: (await repo.listStandupsForUser(x.userName)).length - 1,
})),
Expand Down
Loading