Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
dd1a542
refactor(api): move tasks and processes into core
artem-broccoli Nov 14, 2026
ae860dc
refactor(api): processes follow tasks through events and plug feature…
artem-broccoli Nov 14, 2026
09da3e2
fix(processes): leave a run whose record is gone for the daily check
artem-broccoli Nov 14, 2026
600a324
fix(processes): refuse a malformed task record id as not found
artem-broccoli Nov 14, 2026
2585d66
refactor(tasks): a task is an aggregate that owns its rules
artem-broccoli Nov 14, 2026
0cdb0e7
refactor(processes): a run is an aggregate that holds its steps
artem-broccoli Nov 14, 2026
1a741f1
fix(processes): save only a step's status, leaving its task reference…
artem-broccoli Nov 14, 2026
d211297
refactor(api): ports and adapters get their own folders
artem-broccoli Nov 15, 2026
81bb34c
refactor(utils): counting working days moves to utils
artem-broccoli Nov 15, 2026
22e7167
refactor(processes): run steps table becomes process_run_step_progress
artem-broccoli Nov 15, 2026
b246b23
refactor(processes): process definition types read top-down
artem-broccoli Nov 15, 2026
9c0fa7a
docs(processes): show a run from a change and from a date in JSON
artem-broccoli Nov 15, 2026
9a7a48e
docs(processes): format the run examples
artem-broccoli Nov 15, 2026
e412f53
refactor(processes): name the step progress table runStepProgress in …
artem-broccoli Nov 15, 2026
9cc0c4c
refactor(processes): drop startableByAdmin; an admin may start any pr…
artem-broccoli Nov 15, 2026
3d81a69
docs: module.md opens with JSON examples of its main flow (RULE77)
artem-broccoli Nov 15, 2026
ee61468
refactor(processes): a step keeps key and kind at its root and its ki…
artem-broccoli Nov 15, 2026
fc537bc
refactor(processes): an event trigger is a class that knows which eve…
artem-broccoli Nov 15, 2026
5e476ba
Revert "refactor(processes): an event trigger is a class that knows w…
artem-broccoli Nov 15, 2026
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
12 changes: 7 additions & 5 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This document records Broccoli's architecture decisions and their reasons. It is

```
packages/api/src/
├── core/ shared infrastructure and instance-wide concerns
├── core/ shared infrastructure, instance-wide concerns and the primitives features build on
├── modules/ business modules, one folder per domain
├── integrations/ clients of external services; know no business module
└── utils/ helpers that know nothing about Broccoli
Expand All @@ -22,7 +22,7 @@ Each package declares its allowed dependencies, enforced by lint with no cycles
- **Composition.** Startup supplies each module with only the dependencies it needs and collects its API, routes, event handlers and jobs. Modules communicate through public operations or events, never through another module's tables ([RULE21](code-quality.md#layers-and-place)).
- **Domain-driven design is tiered.** A plain domain layer with unit tests is the default. Aggregates, value objects and domain events are introduced where invariants demand them, with each business decision owned in one place ([RULE52](code-quality.md#layers-and-place)).
- **Demo data.** The demo module owns the sample company and coordinates other modules' public operations in one transaction; it owns no tables. Exiting the demo removes the sample company and people added during it, then returns to setup. A regular owner keeps their account; a provisional owner follows the separate lifecycle in §6.
- **Four primitives.** HR features share records, tasks, calendar and processes, so each feature does not grow its own task queue, history and approval engine. A record stays a typed entity in its owning module; shared capabilities do not turn records into a generic storage engine. Shared record capabilities live in `core/records`, which modules use without core knowing them; history is the first. Tasks live in the tasks module: a feature or process step asks one person for work, and the feature's own command finishes it, so a task never grants authority. Processes live in the processes module: a published version of a process says when it starts and which steps give whom a task; each run of it follows those tasks to its end. Calendar is not built yet. Domain-specific rules, such as leave balances, stay with the feature that owns them.
- **Four primitives.** HR features share records, tasks, calendar and processes, so each feature does not grow its own task queue, history and approval engine. A record stays a typed entity in its owning module; shared capabilities do not turn records into a generic storage engine. Shared record capabilities live in `core/records`, which modules use without core knowing them; history is the first. Tasks and processes live in core beside records, and features plug into them through registrations and contracts they fill, so core never imports a module. Tasks are in `core/tasks`: a person, a feature or a process step asks one person for work, its assignee marks it done, and only the person who asked deletes it; a task never grants authority. Processes are in `core/processes`: a published version of a process says when it starts and which steps give whom a task; each run of it follows those tasks to its end. A record type a run may be about registers how processes read it (who it is about, its dates), the module that publishes an event registers it as triggering, and the employees module fills the people and working-calendar ports processes reads through. Calendar is not built yet. Domain-specific rules, such as leave balances, stay with the feature that owns them.

### Module layers

Expand All @@ -31,10 +31,12 @@ Layers live inside the module that owns the feature. For example, a company logo
- **`domain` — business decisions.** Holds business facts, invariants, calculations and valid state transitions. It works on domain values without I/O or knowledge of storage, requests or frameworks. A business decision has one owner here, so every caller applies the same rule.
- **`db` — persistence.** Owns tables, queries, persistence constraints, locking and mapping between stored data and domain objects. Repositories hide SQL and ORM details and return whole domain items, with encrypted secrets and facts derived from other records read separately, so callers do not assemble business state from storage fragments.
- **`app` — application coordination.** Use cases and services coordinate authorization, domain decisions, repositories, integrations, transactions and events or jobs. This layer owns the operation's workflow and transaction boundary. It gathers the facts a domain decision needs and applies its result without duplicating that decision.
- **`ports` — what a module needs from others.** Interfaces the module's `app` reads through, which another module implements, such as the people processes reads. Keeping them in their own folder shows each module's outside dependencies at a glance.
- **`adapters` — implementations of another module's ports.** Each adapter fills a port with this module's services and repositories; startup hands it to the module that owns the port.
- **`graphql`, `http`, `mcp` — transport adapters.** Handle request parsing, caller identity, transport-specific responses and error translation. They delegate operations to the application layer, so permissions and business behavior remain the same for the web app and agents.
- **`helpers` — module-local technical work.** Holds concerns such as parsing an origin or hashing a secret. Every layer may use these helpers, which cannot depend back on those layers. This keeps technical concerns out of the domain model; helpers with no Broccoli knowledge belong in `utils`.

Dependencies point inward: adapters → `app` → `db` → `domain`; `app` also uses `domain` directly. Layers expose their public contracts through their folder's entry point. The allowed imports are enforced, rather than relying on convention ([RULE16, RULE17, RULE58](code-quality.md#layers-and-place)).
Dependencies point inward: transport and `adapters` → `app` → `db` → `domain`; `app` also uses `domain` and its own `ports` directly. Layers expose their public contracts through their folder's entry point. The allowed imports are enforced, rather than relying on convention ([RULE16, RULE17, RULE58](code-quality.md#layers-and-place)).

**Use cases and services.** A use case represents an action a caller can perform. Related operations on one subject form a service, keeping that subject's lifecycle and formats together; every `app` subject is a service, even with one operation. Adapters translate each operation into one use-case call and retain only transport concerns such as cookies and redirects ([RULE18](code-quality.md#layers-and-place)).

Expand Down Expand Up @@ -82,8 +84,8 @@ For example, a domain rule decides whether an import source may retry. The appli
- **Events reach handlers at two moments.** Before the commit, a module's before-commit handler receives all of a transaction's new events at once, inside it, round after round while handlers publish more (at most ten rounds); its failure rolls the transaction back, so its reaction commits with the change or not at all. After the commit, in-process handlers run sequentially and are awaited; their failures are logged without undoing the committed operation, and a crash between commit and dispatch can lose an event, an accepted limitation for them. Rollback drops both.
- **Record changes publish `records.changed`**, one event per history line with the record, the operation, the changed field keys and the cause, never values. It notifies; processes trigger on domain events instead.
- **Durable delivery after the commit is a transactional outbox**, a pg-boss job queued with the business change, for reactions that are slow or call outside systems. It is not implemented yet; this avoids an external queue or a second workflow engine.
- **Workflows** such as approvals and onboarding are domain state machines, with durable jobs executing their steps. A process starts from a trigger, never from a feature calling it: a record's date starts it by a daily job, and a domain event a module lists as triggering (a person added, a task created or its status changed) starts it in the transaction that published the event, one run per event and record, batched per process like a bulk save. A run that cannot start fails that change. A step's task is finished through the process, which changes the task and the run in one transaction.
- **A process acts as the system on its own and as the person when they act.** A run's own writes, such as creating its tasks or cancelling itself, run as the system with the run as their history cause (`PROCESS_RUN`); an assignee marking their step done, or an admin starting or cancelling a run, acts as themselves under their own permissions. Who acts for a step that calls another module is not decided yet.
- **Workflows** such as approvals and onboarding are domain state machines, with durable jobs executing their steps. A process starts from a trigger, never from a feature calling it: a record's date starts it by a daily job, and a domain event its publishing module registers as triggering (a person added, a task created or its status changed) starts it in the transaction that published the event, one run per event and record, batched per process like a bulk save. A run that cannot start fails that change. A step's task is finished with the ordinary task command; processes follows the task's status event before the commit and completes the step and the run in the same transaction. A transaction that locks a task and its run takes the task first.
- **A process acts as the system on its own and as the person when they act.** A run's own writes, such as creating its tasks or cancelling itself, run as the system with the run as their history cause (`PROCESS_RUN`); an assignee marking their step's task done, or an admin starting a run, acts as themselves under their own permissions. Who acts for a step that calls another module is not decided yet.
- **A run notices removals when it next runs.** Nothing reacts to a person's removal: a step still waiting on a task whose assignee was removed, or a removed subject, cancels the run the next time a command or the daily job touches it, and the cancellation commits instead of being thrown.

## 6. Auth and sessions
Expand Down
3 changes: 2 additions & 1 deletion docs/code-quality.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ A rule that lint enforces ends with its lint rule, and `pnpm check` fails when i
- **RULE74.** Keep the main path flat: guard clauses return early, and a condition that needs reading becomes a named predicate (`canBeInvited`, `noteHasLink`).
- **RULE75.** A literal whose meaning is not obvious becomes a named constant with a doc comment saying why it is that value (`MAX_BODY_BYTES`, `FILE_REQUESTS_PER_MINUTE`).
- **RULE71.** Types read top-down: the types in the main exported function's signature come first, then each type they reference, in the order they are first referenced.
- **RULE10.** A service or repository opens its file with its contract: an exported interface carrying the doc comments. Its factory returns that interface, not `ReturnType<typeof …>`. A repository's factory takes the caller's `Orm` or `Tx`, per operation. A service's factory, `createXService`, takes only the dependencies it needs; its members are declared as functions (`readOwner: (db: Orm | Tx) => …`), so callers may pass them on as callbacks. A service file exports no instance: each caller calls the factory once, at the top of its file, or inside its own factory when it needs that factory's dependencies or an import cycle requires it. Only `#test/support` shares instances, of services without dependencies. An `app` folder's `index.ts` exports services, ports, registrations, constants and types, never a loose business function. `lint: no-restricted-syntax` (the `ReturnType` part)
- **RULE10.** A service or repository opens its file with its contract: an exported interface carrying the doc comments. Its factory returns that interface, not `ReturnType<typeof …>`. A repository's factory takes the caller's `Orm` or `Tx`, per operation. A service's factory, `createXService`, takes only the dependencies it needs; its members are declared as functions (`readOwner: (db: Orm | Tx) => …`), so callers may pass them on as callbacks. A service file exports no instance: each caller calls the factory once, at the top of its file, or inside its own factory when it needs that factory's dependencies or an import cycle requires it. Only `#test/support` shares instances, of services without dependencies. An `app` folder's `index.ts` exports services, registrations, constants and types, never a loose business function. `lint: no-restricted-syntax` (the `ReturnType` part)
- **RULE11.** Parse, don't validate: turn raw input into a typed value once, at the boundary (`parseCalendarDate`, `parseNewEmployee`, `parseSearch`), and pass that value on. Never check with an `isX` function and then keep using the raw input.
- **RULE12.** Build only what the task needs: no wrappers, pass-through files, steps, options or interfaces nobody uses. Call the helper directly.
- **RULE55.** Prefer deep modules to wide ones: a module, service or function hides much work behind a small interface. Callers make one call that does the whole job (`storeFile`), never a series of small steps they must know to run in order; a new need adds work behind the existing interface before it adds another export or parameter.
Expand Down Expand Up @@ -108,3 +108,4 @@ How to write each kind of test is in [`testing.md`](testing.md).
- **RULE76.** A code comment says why, never what the next lines do; code that is no longer used is deleted, not commented out.
- **RULE49.** `thesaurus.md` gets a term only when it is Broccoli's own, not a well-known one such as job, model or mapping. A term names its context, so it reads alone (`Change author`, not `Author`).
- **RULE50.** A change that needs a new architecture decision changes `architecture.md` in the same pull request.
- **RULE77.** A module's `module.md` opens, after its one-paragraph summary, with JSON examples of its main flow: what comes in, the rows it writes and what goes out. Rules and the data model follow.
9 changes: 4 additions & 5 deletions docs/qa/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,14 @@ A manual check of My tasks, against a development stack.

## Mark done

- [ ] Mark done on a row turns it to Done ("Done by you · <today>"), the count goes down by one and a toast says "Marked done" with Undo.
- [ ] Undo turns it back to To do. After a reload a done task sits under Resolved recently.
- [ ] Mark done on a row turns it to Done ("Done by you · <today>"), the count goes down by one and a toast says "Marked done", with no Undo. After a reload the done task sits under Resolved recently.

## A task

- [ ] Clicking a row opens the task at `/tasks/<id>`: the asker's avatar, the title, the status, "What Priya asks" with the description and due date, "If you mark it done: Priya sees it as done in Tasks." and "Asked by Priya Shah on …".
- [ ] History lists "Broccoli asked for “…”" for a demo task; Mark done adds "You marked it done" at the top without a reload, and Reopen adds "You reopened it".
- [ ] History lists "Broccoli asked for “…”" for a demo task; Mark done adds "You marked it done" at the top without a reload.
- [ ] A waiting task has no Mark done and says "It cannot be marked done while it waits."
- [ ] On a done task you hold, "…" offers Reopen, which turns it back to To do.
- [ ] On "Check the laptop budget for new starters", which you asked for, "…" offers Cancel task. Confirming without a reason says "Say why you are cancelling it."; with one, the task shows Cancelled and "Cancelled on …: <reason>".
- [ ] A done task you hold has no "…" menu: done is final.
- [ ] On "Check the laptop budget for new starters", which you asked for, "…" offers Delete task. "Delete this task?" names Priya; Keep it leaves it, Delete task closes the panel, says "Task deleted" and the task is gone from Waiting on others.
- [ ] An unknown id, such as `/tasks/00000000-0000-4000-8000-000000000001`, says "This task is not yours to see, or it is gone."
- [ ] At phone width the panel is full screen.
2 changes: 1 addition & 1 deletion packages/api/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { codegenMappers as externalAgentMappers } from "./src/modules/externalAg
import { codegenMappers as importMappers } from "./src/modules/imports/codegen";
import { codegenMappers as mcpAgentMappers } from "./src/modules/mcpAgents/codegen";
import { codegenMappers as systemMappers } from "./src/modules/system/codegen";
import { codegenMappers as taskMappers } from "./src/modules/tasks/codegen";
import { codegenMappers as taskMappers } from "./src/core/tasks/codegen";
import { codegenMappers as timeOffMappers } from "./src/modules/timeOff/codegen";

const config: CodegenConfig = {
Expand Down
7 changes: 1 addition & 6 deletions packages/api/drizzle/0036_tasks.sql
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@ CREATE TABLE "tasks" (
"assignee_employee_id" uuid NOT NULL,
"asked_by_employee_id" uuid,
"subject_employee_id" uuid,
"process_key" text,
"process_run_id" uuid,
"step_key" text,
"record_type" text,
"record_id" text,
"due_date" date,
Expand All @@ -20,9 +17,7 @@ CREATE TABLE "tasks" (
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "tasks_title_not_blank" CHECK (btrim("tasks"."title") <> ''),
CONSTRAINT "tasks_asked_by_someone" CHECK ("tasks"."asked_by_employee_id" is not null or "tasks"."process_key" is not null),
CONSTRAINT "tasks_process_source_complete" CHECK (("tasks"."process_key" is null) = ("tasks"."step_key" is null)
and ("tasks"."process_run_id" is null or "tasks"."process_key" is not null)),
CONSTRAINT "tasks_asked_by_someone" CHECK (("tasks"."type" = 'manual_to_do') = ("tasks"."asked_by_employee_id" is not null)),
CONSTRAINT "tasks_record_complete" CHECK (("tasks"."record_type" is null) = ("tasks"."record_id" is null)),
CONSTRAINT "tasks_columns_follow_status" CHECK (("tasks"."status" = 'done') = ("tasks"."done_at" is not null)
and ("tasks"."done_at" is null or "tasks"."cancelled_at" is null)
Expand Down
Loading
Loading