Skip to content

WS-27 + WS-28: the Projects app becomes usable, the People Center ships, and the Tasks app narrows to personal - #374

Merged
vjvarada merged 12 commits into
mainfrom
claude/paca-research-task-management-a1f6zd
Aug 7, 2026
Merged

WS-27 + WS-28: the Projects app becomes usable, the People Center ships, and the Tasks app narrows to personal#374
vjvarada merged 12 commits into
mainfrom
claude/paca-research-task-management-a1f6zd

Conversation

@vjvarada

@vjvarada vjvarada commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Follows PR #367 (WS-27a/b/d), which is merged. 12 commits across three workstreams.

⚠️ This PR grew past what it was opened for — it started as WS-27e and now spans WS-27d/e/f/i, WS-28a/b, and a Tasks-app scope change. 69 files, +7,956/−1,494. That happened because each step was directed in sequence and the branch is pinned, not because it was planned as one change. If you would rather review it in pieces, say so and I will split it — that needs your go-ahead because it means pushing to branches other than the designated one.

⚠️ CI has not run on any of this. GitHub Actions stopped picking up jobs at ~18:11 today: pushes produce no check runs at all, and one stale run is still queued against a long-dead sha. It is not this diff — the same suites went green on d4667a6 at 14:28 in ~90 seconds. Everything below was verified locally, and each commit records its own counts. Likely Actions minutes/billing or an org runner outage; worth a look at Settings → Billing.


What this makes true

/projects was deployed and inert: the schema was applied and the feature granted, but the UI could read and drag and never create. A member could only work with rows a ClickUp import had put there, and "stop using ClickUp" was impossible on its face. It can now be used — and the People Center it depends on exists.

1 · Projects (WS-27d/e/f/i)

One task store, not a mirror. D-PM-6 was specced as a mirror between pm_tasks and gtd_items; revised, owner-directed, to one store. A task assigned to you is the row in your inbox — completing it there completes it for the project, same instant, same row. Private work is a personal project (personal_owner); the GTD overlay is per-member, so the person doing a task can say NEXT while the person who delegated it says WAITING. Dispositions are derived, never written when untriaged — storing one on first read would permanently empty the Weekly Review.

"My work" sits above the project tree in the same app. Four lanes not eight; empty work lanes still render ("you have triaged nothing into today" is a real state); undated tasks sort below dated ones; untriaged is counted in the header.

Authoring: new department, subproject (from the node — the parent is already on screen), task, subtask, and assignees as chips where an agent and a person share one field. That is where D-PM-4 stops being a schema note, and it is the precondition for dispatch being reachable.

Automation (WS-27f) — both halves of workflows_app.md §13. A pm_task node lets the one engine act on a task, writing through a transport-free service so an automation's edit is indistinguishable in validation from a human PATCH. Status is set by lane name, never by status_id — a graph carrying one project's UUID could only ever automate that project. Assignment to agent:<name> starts a real run, from an event sink so a broken agent cannot fail the act of assigning somebody a task.

Attachments (WS-27i) — one file store: gtd_attachments reused, upload rules imported. pm_task_attachments is a thin join that carries the access decision, so a file is readable by whoever can see the task rather than only its uploader. There is deliberately no attach-by-id endpoint: naming an arbitrary attachment id would let a caller attach somebody else's private capture to their own task and read it back.

2 · People Center (WS-28a/b)

WS-28a fixes gtd_people's key shape: migration 49 made name UNIQUE and left email unconstrained, which is backwards for a directory that joins on email. Its unnamed consequence — the HR importer upserts ON CONFLICT (name) and would have broken — is handled with a source_key. Neither new constraint can block a deploy: a duplicate address is quarantined into email_conflict rather than failing CREATE UNIQUE INDEX, and the status CHECK is NOT VALID then validated in a guarded block.

WS-28b ships /people — directory, person page, four panels. Its own feature slug, but the HR projection is imported from tasks.core and a test asserts the function's identity. Three filters drop without admin:members:read so search cannot become an oracle for the hidden field, and the response says hr_visible so the UI shows "restricted" rather than a blank strip that reads as "nobody filled it in". Load is computed from open assigned tasks and carries unestimated, because a bar built from the estimate sum alone shows someone holding thirty un-estimated tasks as completely free.

3 · Tasks app narrows to personal

Owner-directed. The Projects and People views are removed — the first was literally the ClickUp Space → Folder → List tree, the second the HR roster. GtdProject survives as an association; what went is the browsing surface.

loadPeople() had exactly one caller: opening the People view. Deleting it naively would have left the delegate picker offering nobody — a broken picker that reads as a company with no people. Both callers now load the roster where they need it.

4 · Plans, not code

workflows_app.md §13 — the Paca engine backlog, U1–U8, each with a done-when. project_management_app.md §11 — the ClickUp-parity gap, measured against the built tree, ten items ordered by what stops you using this on a Monday. One finding worth surfacing: bulk edit gates the cutover, because an import nobody can re-triage in bulk is one somebody abandons halfway, leaving two live systems.


Three bugs caught before shipping

  • The projects BFF proxy re-serialised every POST as JSON — a multipart upload would have reached the gateway with no file at all, while still answering 201.
  • TaskPanel read the selected project's statuses, wrong for a task opened from My work, offering transitions to lanes that do not exist on that task's project.
  • templating.resolve_value keeps an unresolvable {{ref}} as-is at run time by design, and {{trigger.missing}} passes validate_graph because its root is legal. Fixed for the new node; every other node type has the same exposure, recorded as its own ticket rather than widened here.

Verification (local)

  • 435 Python cases at the widest run; 377 vitest (was 315); tsc --noEmit clean; ruff clean on every new file; xenon passes CI's actual gate.
  • ~60 mutants measured red and reverted byte-identical. Four survived first time and every one exposed a weak assertion rather than a weak guard — a balanced fixture, a check watching for a diff when updated_at was the thing that moved, a substring passing on a file's own explanatory prose, and a traversal test checking a path that was already safe by construction.

What this does NOT do

  • Does not run either ClickUp import endpoint. Both are built and tested; neither has ever been run. Production execution is a registered owner gate.
  • Does not apply any migration. 146–150 are unapplied.
  • Does not touch gtd_items. WS-27h owns that retirement.
  • Leaves one capability unreachable, on the record: creating/editing a person and uploading a résumé. PersonEditor was its only UI. The API and store wrapper are untouched — nothing was deleted — but until the People Center grows the write half an admin cannot do it from the product. Filed as WS-28b-write.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W

claude added 7 commits August 6, 2026 09:35
…ot a copy

D-PM-6 is REVISED, owner-directed: "the personal task manager should be a
proper extension of the project system … a cohesive whole that should fit
within each other." The spec had it mirroring pm_tasks into gtd_items as
SYNCED rows. A mirror is two rows for one fact, and every feature built
afterwards — search, calendar, agents, reporting, the weekly review — would
have had to know about both. So there is now one task table.

Three consequences, and they are the design:

- Assignment is not a sync. A task assigned to a member IS the row in their
  inbox. Completing it there moves the shared status at the same instant,
  because there is one row — a personal-only "done" would be a member
  quietly ticking off a team task while the board still shows it open, which
  is exactly the drift a mirror produces.
- Private work is a personal project: an ordinary pm_projects row carrying
  `personal_owner`, granted to that one address. Nothing about tasks,
  boards, timelines, automation or agent dispatch needs a special case. They
  are excluded from team reads because "My tasks" is not a department — that
  is presentation, not access; the grant already scopes it to one person.
- The GTD overlay is PER MEMBER (pm_task_personal). Two people assigned the
  same task hold different dispositions: the one doing it says NEXT, the one
  who delegated it says WAITING. A single column on pm_tasks could not
  express that, and it is what delegation looks like rather than an edge
  case.

`disposition` is NULL until a member triages, and the read derives one from
the task's status using the same lens routes/tasks/sync.py has always
applied. So "never looked at" and "deliberately filed to INBOX" stay
distinguishable — the only question the Weekly Review exists to ask, and a
column defaulting to 'INBOX' would have destroyed it silently. Filtering
matches the EFFECTIVE disposition, or "show me my next actions" would answer
empty for somebody with twenty assigned tasks.

The overlay route cannot write pm_tasks at all, and the task routes cannot
write the overlay. That makes "the overlay is never clobbered" — a contract
the ClickUp sync has honoured by discipline since migration 48 — structural,
in both directions.

Cost accepted and recorded: gtd_items becomes legacy and WS-27h retires it
(spec §7.5) — a second retirement running beside ClickUp's. items.py's 27
owner-scoped predicates are untouched here on purpose; they belong to that
ticket.

Also mints WS-28 (specs/people_center_app.md) — the People Center's UI/UX,
scope owner-set to directory, skills, org chart, capacity and seats. The
fact it exists to settle: there are TWO people stores deliberately —
app_user answers "can they sign in", gtd_people answers "who are they and
what can they do" — and the directory must include people with no login,
which is why the assignee is a plain string. It also records a real defect
to fix before the join is relied on: migration 49 made `name` UNIQUE and
left `email` unconstrained, so two rows may share an address today.

Verification: 364 passed across the projects, org-access and CRM suites; 31
new hermetic cases. Six mutants measured red and reverted byte-identical.
⚠️ TWO of them first SURVIVED, and the fake was at fault rather than the
tests: it applied the inbox's arms unconditionally instead of keying them
off the statement text — the exact mirror failure _projects_fakes.py's own
docstring warns about, found by mutation and not by review.

Not deployed: migration 147 has not been applied anywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
The Projects app (WS-27) needed automation and deliberately did not build one:
ADR-028 and D6 make `/workflows` the only engine, so a Projects-owned rules
engine would have been a single-owner violation. WS-27 therefore contributes
events and node types, and the engine-shaped findings belonged in the engine's
own spec. Until now they were one sentence of demand ("multi-branch switch,
step snapshots, dependency map") with no acceptance standard behind it — which
is not enough for anyone to pick the work up without re-deriving it from Paca's
source.

`workflows_app.md` §13 writes it down. Eight items, each pairing Paca's design
(cited to `000027_add_automation_graph.sql` and `worker/automation_consumer.go`,
not to Paca's architecture docs — those describe a dropped v0.10 design and
claim the wrong web framework) with this engine's *measured* current state and a
done-when written to §8.3's standard: an assertion a test can make, never a
screenshot.

What the measurement turned up:

- §13.1 records that the binding already half-ships. Eleven `pm.*` topics reach
  `dispatch_event` today via the seam the ClickUp receiver uses; the engine can
  hear about projects and cannot act on them, because no node type touches an
  internal app. That asymmetry is U1 and is all WS-27f's first half is.
- U3 is a promise already in writing that the schema does not keep: §1.2 G6 and
  §2 F9 both say "per-node inputs/outputs", but `node_results` records only the
  output. When a `{{ref}}` resolves wrong, the run history shows the wrong
  output and no way to see the wrong input — the commonest debugging question a
  maker has, currently unanswerable without a re-run.
- U6 splits a Paca feature in half rather than adopting it: their per-fire
  bookkeeping tables are refused (CAS on `last_fired_at` is stronger — one
  catch-up fire, clock skew included), but they also have a `due_date_reached`
  trigger and we have no equivalent at all.
- U5 is marked blocked on §8.3b, on purpose. A `predecessor_done` join built
  before 8.3b settles the merge shape would either invent a second join
  semantics or quietly constrain the first.

§13.4 records the five refusals as decisions, so a later reader does not "fix"
them — including Paca's `call_api`, whose stored headers are visible to any
project reader, a gap its own source comments acknowledge.

Kept out of the slice sequence deliberately: folding U1–U8 into Slice 3 would
repeat exactly what §8.3's truth pass corrected, a one-line slice hiding several
unrelated problems. §13 changes neither Slice 3 nor Slice 4, and claims nothing
as shipped.

Cross-references updated both ways so the three docs agree: the research
appendix and the Projects spec now point at §13 by item, and §13 points back at
the evidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
… app

WS-27e shipped API-only. The one-store revision's whole point was cohesion —
one task row, so finishing something in your own list finishes it on the team's
board — and until now that was true in the schema and invisible to a member,
who still had no way to see their own work in this app at all.

"My work" sits ABOVE the project tree, in the same pane, reached from the same
nav entry. Putting it anywhere else would re-teach the exact split D-PM-6 was
revised to remove.

Four layout decisions, each of which could have gone the other way:

- Four lanes, not eight. INBOX/NEXT/WAITING/SOMEDAY are work states and get
  lanes; PROJECT/REFERENCE are filing states and collapse into one "Filed" lane
  shown only when occupied; DONE/TRASH the endpoint already excludes. Eight
  lanes makes the daily view a filing cabinet.
- Empty work lanes still render. "You have triaged nothing into today" is a
  real state and a lane that vanishes when empty cannot say it.
- Undated tasks sort BELOW dated ones. A task nobody dated is not more urgent
  than one due tomorrow, and the opposite order is how a list stops being read.
- Untriaged is stated in the row and counted in the header, rather than implied
  by a missing badge. That count is the Weekly Review's whole question, and it
  is only answerable because the server derives dispositions instead of storing
  them on first read.

The checkbox calls /tasks/{id}/complete, which moves the SHARED status, and
says so in its title — the cohesion is the feature, not a footnote. Triage
buttons call /tasks/{id}/personal and structurally cannot touch a shared field.

One repair the surface forced, and a real bug: TaskPanel read the *selected
project's* statuses. That is wrong for a task opened from My work, which may
belong to any project the member is assigned into — the panel would have
offered transitions to lanes that do not exist on that task's project. Statuses
are now resolved from the task's own root project, with the board and list
paths going through the same resolver.

Reloading is a dependency bump through one cancellable effect rather than a
callback, so the first fetch, a context switch and a post-mutation refresh all
take the same path and a slow response from a context the member has already
left cannot land. Mutations reload rather than patching local state: these
endpoints move shared status and derive dispositions server-side, so a
locally-guessed row would be a guess about two other systems.

Verification: 332 vitest cases green (was 315), tsc --noEmit clean, eslint adds
zero errors over the pre-existing WS-27d baseline. Seven mutants measured red
against lib/mywork.ts and reverted byte-identical — including one that survived
first time (untriagedCount counting the triaged instead) because the fixture was
balanced two-against-two; the fixture is now lopsided and the mutant dies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
…btasks, assignees

WS-27d shipped a UI that could read and drag but never create. `createProject`,
`createTask` and `setAssignees` were in the client and wired to nothing, so a
member could only work with rows a ClickUp import had already put there — which
makes the app unusable for the thing it is for, and makes WS-27g's "stop using
ClickUp" impossible on its face.

Four surfaces, each placed where the answer already is:

- New department from the sidebar header; new subproject from a + on the node
  itself. The parent is on screen — a dialog asking "which parent?" is how a
  fifty-node tree acquires mis-parented rows.
- New task from a one-field row above the board. Status is deliberately not
  sent: create_task picks the project's default, so the browser never has to
  know which lane a new task starts in.
- Subtask from the task panel. A subtask is a task with a parent — one
  endpoint, one table, so it inherits statuses, timeline and assignment whole.
- Assignees as removable chips plus one input.

The last one is where D-PM-4 stops being a schema note. An agent and a person
go in the same field and the only difference on screen is an icon, so handing
work to an agent is literally the same gesture as handing it to a colleague.
That is also the precondition for WS-27f's dispatch being reachable at all,
since pm.task.assigned is what it keys off.

Two details in lib/assignees.ts that are load-bearing rather than tidy:

- withAssignee returns the SAME array when the assignee is already present, and
  the caller skips the PUT on that identity. A re-assert would emit
  pm.task.assigned and re-dispatch an agent run.
- parseAssignees splits on commas, semicolons and newlines but never on spaces.
  A pasted "Priya <priya@x.com>" would otherwise shred into tokens that assign
  work to nobody.

classify() is a hint, not a rule: the server accepts any non-empty string, so
an unrecognised token gets a muted chip and a tooltip rather than a refusal —
inventing a client-side rule the API does not enforce is how a UI starts
rejecting valid data. The failure worth surfacing is the typo that silently
assigns nothing.

Also fixed while here: a new subtask was invisible on the board until you
switched projects, because onChanged merges one known task and a subtask is a
row the board has never seen. The panel now signals a real reload for that case
only, so a comment does not cost a project fetch.

Verification: 349 vitest cases green (was 332), tsc --noEmit clean, eslint adds
zero errors over the pre-existing baseline. Seven mutants measured red against
lib/assignees.ts and reverted byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
PR #374 went un-mergeable when main landed WS-26d (the CRM agent's read half,
plus the deploy fixes). One text conflict, in work_plan.md, in two blocks —
the same additive shape as the last merge: main rewrote its WS-26 rows while
this branch added WS-27/WS-28 rows beside them.

Took main's WS-26 lines, kept ours for WS-27 and WS-28.

Worth stating why the WS-27 side is not a coin-flip. Main's copy of that row
still describes WS-27e as a MIRROR — "mirrored into gtd_items via an internal
commandcenter provider" — which is the design D-PM-6's revision replaced with
one store. Keeping main's text would have re-asserted a dropped design in the
dispatch board, and the same applies to the WS-27 line in the single-owner
registry, where main still points at the old "recorded there as backlog"
sentence that this branch replaced with the §13 U1-U8 pointer. Diffed both
directions first: everything present only on main's side of those two rows is
superseded text, so nothing was lost by taking ours.

Checked for the silent conflict that bit the last merge: main added NO new
migrations, so 146/147 are still free and there is no repeat of the 145
collision that git cannot flag because the filenames differ.

Verified on the merged tree: 283 passed across the five projects suites plus
the three org-access suites, 349 vitest cases green, tsc --noEmit clean, ruff
clean on the projects package and its tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
#374 went dirty again: main landed the WS-26d gate truth pass, the Deals
cursor fix, and the email transaction fix. One conflict, in work_plan.md,
in the same WS-26/WS-27/WS-28 row block as last time.

Took main's WS-26 line — it now carries real new content (d-email, d-autolead
and d-write dispatchable, with their anchors) — and kept ours for WS-27 and
WS-28. Diffed both directions again rather than assuming last time's answer
still held: everything present only on main's WS-27 side is still the MIRROR
text ("`pm_tasks` mirrored into `gtd_items` via an internal `commandcenter`
provider"), which D-PM-6's revision replaced with one store. Taking it would
re-assert a dropped design.

Main added no migrations, so 146/147 remain uncollided.

Verified on the merged tree: 283 passed across the five projects suites and
three org-access suites, 349 vitest green, tsc --noEmit clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
The two halves of workflows_app.md §13 — U1 (a task-mutation node) and U7
(agent dispatch). Before this the engine could HEAR about projects and could
not act on them: eleven pm.* topics have been reaching dispatch_event since
WS-27a, but no node type touched an internal app. And assigning agent:<name>
emitted an event that nothing consumed.

U1 — the pm_task node.

The engine imports a transport-free SERVICE, not a route.
projects/automation.apply_task_patch reuses apply_status_transition,
update_row and record_activity, so an automation's edit is indistinguishable
in validation from a human PATCH and lands the same timeline row. That is
Paca's "mutate through the ordinary service" rule made structural rather than
conventional — an engine writing pm_tasks directly would produce tasks whose
history does not explain how they got that way.

Status is set by lane NAME (category as a fallback), never by status_id.
Statuses are per-project rows, so a graph carrying one project's status UUID
could only ever automate that project — the opposite of what an automation is
for. An unknown lane fails with the project's actual lane names, because
"status not found" sends the maker to guess at a vocabulary one query away.

update_task deliberately takes no actor. The engine says what to change; the
wiring decides who, and for an automation that is always
system:workflow:<workflow_id>, never whoever tripped the trigger. Passing an
actor the implementation would ignore is how two answers start disagreeing.

One multi-field node, not one per field: Paca merged five single-field actions
into update_task and recorded it as a consolidation lesson. Adopting the end
state is cheaper than rediscovering it.

A pm_task node is NOT write-class. write_without_approval exists for outward
writes through the Integration Registry; nobody wants an approval step on a
task moving to Done. That was already true and true by accident — it is now
pinned by a test.

U7 — assignment is dispatch.

A second event sink beside the workflows dispatcher, not a call inside PUT
/tasks/{id}/assignees: a slow or broken agent must not be able to fail the act
of assigning somebody a task. Only newly added assignees dispatch, which the
emitter already guarantees by sending the added set rather than the whole set,
so a re-assert cannot start a second run.

The handoff activity is committed BEFORE the run starts (Paca's
agent.session.started), and the failure path writes too. A dispatch that fails
silently leaves a session that appears to be running forever and nobody knows
to pick the work back up.

An engine defect found while building this, fixed for this node and recorded
as general: templating.resolve_value keeps an unresolvable {{ref}} as-is at
run time BY DESIGN, and {{trigger.missing}} passes validate_graph because its
root is legal. The literal would have reached Postgres as a would-be uuid and
returned "Task not found" — pointing the maker at a missing task rather than
at their reference. Every other node type has the same exposure; widening the
fix touches every handler and belongs to its own ticket, so §13 says so.

execute_node sat at exactly the C901 ceiling, so this branch had to be paid
for rather than the ceiling raised: _execute_set was extracted unchanged
alongside _execute_pm_task, and the golden trajectory eval covers both.

Verification: 391 passed across the projects, workflows, org-access and
golden-eval suites; ruff clean on the new files with zero added errors
elsewhere; xenon passes CI's actual gate. Ten mutants measured red and
reverted byte-identical — one survived first time (dropping the pre-write
"already in target state" filter) because the assertion only watched for a
diff. update_row stamps updated_at, so a redundant write is invisible in a
diff while leaving the task looking freshly touched; the test now asserts no
UPDATE is issued at all, and the mutant dies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
@vjvarada vjvarada changed the title WS-27e + WS-27d: one task store, the "My work" lens, and a Projects UI that can actually create things WS-27d/e/f: one task store, the "My work" lens, a Projects UI that can create things, and assignment-as-dispatch Aug 6, 2026
claude added 5 commits August 6, 2026 20:01
… the source

Migration 49 made `name` UNIQUE and left `email` unconstrained. That is
backwards for a directory that has to join on email: two real people cannot
share a name (they do), and nothing stopped two rows carrying the same address
— so an email→person join could silently attribute one person's capacity to
another. The People Center is about to make this table the assignment source,
so the shape is fixed before anything relies on it.

P-1 did not name its own consequence. scripts/import_hr_people.py upserts
ON CONFLICT (name); with that constraint gone there is nothing to infer and
the importer fails outright. So the upsert moves to a `source_key`
(`<source>:<lower(name)>`) with its own partial unique index. That key is
honest about what it claims: the HR snapshot is a JSON object keyed by name,
so names are unique WITHIN THAT FILE whether or not they are unique among
humans. It also means a person hand-added in the People Center is never
overwritten by a snapshot re-import. Backfilled before the constraint is
dropped, while `name` is still guaranteed distinct — which is what makes the
backfill collision-free by construction rather than by luck.

Nothing here may block a deploy, and that shaped both constraints.
apply_migrations.sh replays every 02+ migration on every deploy under
set -euo pipefail and ON_ERROR_STOP=1, and main has already been bitten twice
this month by a migration that stopped deploys. Both new constraints could
plausibly fail on live rows:

- A duplicate address would fail CREATE UNIQUE INDEX. The loser's address
  moves to a new email_conflict column instead — visible, reversible,
  non-blocking. Losing an address silently would be worse than the ambiguity
  this exists to remove; aborting the deploy would be worse than both. The
  winner is deterministic (updated_at, then created_at, then id) so a re-run
  against a restored backup cannot pick differently and move another row's
  address.
- An unanticipated status would fail the CHECK. Migration 49 documented
  'active' | 'inactive' | … and the … is the problem. Known legacy spellings
  map to alumni; anything else is left alone rather than rewritten, because
  silently reclassifying a status nobody anticipated is data loss disguised as
  a cleanup. The constraint is added NOT VALID then validated in a guarded
  block: new writes are enforced either way, and a legacy offender leaves the
  constraint un-validated with a NOTICE instead of stopping the deploy.

The name constraint is found by SHAPE rather than assuming Postgres's default
gtd_people_name_key, and the dropped UNIQUE took its implicit index with it —
so a lower(name) index replaces it, since the directory searches by name.

The importer also drops a duplicate address to NULL at the source, where the
row is still identifiable, rather than discovering it as a psql unique
violation that fails the whole import.

Verification: 334 passed across the new suite plus the GTD, people-scoping and
projects suites; ruff clean. Twelve mutants measured red and reverted
byte-identical. One survived first time and it was the test's fault, not the
migration's: `"NOT VALID" in sql` passed on the file's own explanatory prose
after the keyword was deleted from the statement. The fixture now strips SQL
comments so every assertion reads what Postgres will execute, and a second
mutant was added to prove that (deleting the legacy-status mapping while its
comment survives). One mutant is equivalent and left as such — removing the
`continue` after nulling a blank email changes no behaviour.

⚠️ schema.generated.sql is NOT refreshed: scripts/dump_schema.sh needs a live
database with the ladder applied, which this build had no access to.
Regenerate on the first deploy that applies 148.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
/people lists and filters the org, and a person page shows identity, skills,
capacity and open work. The People Center's "Directory & org chart" sub-app
flips live, closing the read view WS-13 asked for.

Its own feature slug rather than riding feature:tasks. The read API already
existed with the projection this needs, but it is gated on the personal GTD
task manager, and a manager who needs the org chart and the assignee picker
should not have to be handed that to get them. So the GATE is new and the
PROJECTION is imported — core.py re-exports tasks.core.can_read_hr_fields, and
a test asserts the identity of the function object rather than that both agree
today. Two definitions of "may this caller see skills" are two answers waiting
to drift, and this one is a boundary.

The permission story is a projection, not a refusal, and that shaped the rest:

- Three filters are the same rule wearing different hats. The `q` skills
  clause, the `skill` filter and `has_capacity` are all dropped without
  admin:members:read, because matching on a column that is then stripped from
  the response turns the search box into an oracle for the field the
  projection exists to hide. Dropping them silently would be its own defect,
  so the response carries hr_visible and the UI states it once at the top —
  a blank skills strip meaning "you may not see this" and one meaning "nobody
  filled it in" are different facts.
- Load is computed from open assigned tasks, never read from
  current_load_hours_per_week, which is a number somebody typed once and is
  stale the moment anyone assigns anything. It carries `unestimated` because
  an un-estimated task adds no hours: a bar built from the sum alone shows
  somebody holding thirty of them as completely free. When nothing is
  estimated the bar refuses to draw a percentage rather than a confident zero.
- The work panel is scoped by the VIEWER's project grants, not the subject's —
  a Sales lead looking at an Operations colleague sees the work they share,
  not that person's whole life — and answers available:false without
  feature:projects, because "this surface is not yours" and "they have nothing
  open" must not render identically.

Registration is FIVE places, not the four §6 lists. The fifth is
test_org_access_enforcement.GATED_ROUTERS, which is hand-maintained: a router
absent from it is not passing, it is unchecked, and this one serves a person's
skills, capacity and open work. Also added
test_projects_is_registered_on_both_sides, which WS-27a never wrote — the
generic pair passes when BOTH sides are missing a slug, so only a named test
catches a feature nobody registered.

Writes stay on /tasks/people under admin:members:manage, and the /api/people
proxy is GET-only for that reason: forwarding write verbs to endpoints the
gateway does not serve would mint a second, hollow write path.

Two test-design notes. The query rules are tested against a pure
build_directory_filters rather than through a database double, because the
interesting decisions are all "is this clause present at all" — a question
about permissions, not rows, and proving it through a fake proves it about the
fake. And the local double is deliberately NOT _projects_fakes: these routes
emit aggregates and a cross-table existence check it was never built for, and
teaching it those shapes to serve one suite is how a shared fake becomes a
second, worse implementation of Postgres.

Verification: 435 Python cases across the people, org-access, GTD and projects
suites; 377 vitest (was 349); tsc clean; ruff clean on every new file; xenon
passes CI's gate. Eleven mutants measured red and reverted byte-identical,
including the oracle (skills clause added regardless of permission), the
viewer-scoping drop, and hr_visible pinned true.

One repeat of a mistake I made earlier today: the migration assertion first
passed on the file's own explanatory prose. Comments are stripped before
asserting, as in the WS-28a fence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
…ople move out

Owner-directed: "the tasks app will only now be used for managing personal
tasks … we have a separate project view now, and the people will show up in
the people center instead."

Both left-rail views are removed, and what they showed now has a whole app
each. The Projects view was literally the ClickUp Space → Folder → List → Task
→ Subtask tree — precisely what /projects owns — and the People view was the
HR roster and person editor, which is /people.

What did NOT change, deliberately: GtdProject survives as an association. A
task still belongs to a project and every card still names it; what went is
the browsing surface. The clarify flow's Where axis still offers the local
hierarchy, because filing a personal task is a personal act. gtd_people is
untouched — the People Center reads it.

Two things this would have broken silently, and both are the reason to look
before deleting rather than after:

- loadPeople() had exactly ONE caller: opening the People view. Deleting that
  view leaves the delegate picker and the clarify Who axis offering nobody —
  a broken picker that reads as a company with no people. Both now load the
  roster where they actually need it, which is also where it should have been.
- The clarify panel's comment said the Projects view "normally triggers" the
  local-hierarchy load and it was the fallback. It is now the only trigger, so
  the comment was corrected rather than left to mislead.

⚠️ One capability is unreachable and has no replacement yet: creating or
editing a person and uploading a résumé. PersonEditor was its only UI. The API
is untouched (POST/PATCH /tasks/people, POST /tasks/people/{id}/resume, all on
admin:members:manage) and taskStore.uploadPersonResume still wraps it, so
nothing was deleted — but until the People Center grows the write half an
admin cannot do it from the product. Filed as WS-28b-write in
people_center_app.md §7 and stated in task_manager_app.md §6.0, because a
capability that quietly stops working is worse than one that is written down
as missing.

Verification: 377 vitest green, tsc --noEmit clean, eslint clean including the
symbols the removals orphaned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
The owner's standing requirement is "everything I was doing from ClickUp and
more". WS-27g retires ClickUp and cannot honestly be called until the gap is
short, so §11 writes the gap down — measured against the built tree (twelve
pm_* tables, 34 routes) rather than recalled, so every "have" is a table or a
route that exists today.

Ten items, ordered by what stops somebody using this instead of ClickUp on a
Monday rather than by how interesting they are: attachments, notifications and
@mentions, filters/grouping/saved views, custom fields, tags, bulk edit,
recurring tasks, dependency and subtask UI, calendar, global search. Minted as
WS-27i through WS-27r.

Two findings worth more than the list.

WS-27n (bulk edit) GATES WS-27g. The cutover imports a real workspace, and an
import nobody can re-triage in bulk is an import somebody abandons halfway —
leaving two live systems, which is precisely the state the retirement exists
to end. Recorded on the board row, not only in the spec.

Item 3 is a UI ticket, not a schema one: pm_views and
pm_view_task_positions already exist and already carry the presentation-vs-
filters split, so "my open bugs in Ops grouped by assignee" needs a surface,
not a migration. Worth knowing before someone scopes it as a schema change.

Also recorded: what is deliberately NOT on the list. Sprints are a stated
non-goal; time tracking and checklists are things Paca moved OUT of core into
plugins, and its growth path being subtraction is the lesson, not an oversight
to correct. If any is wanted it is a decision to record, not a gap to close.

And the honest other half — several things here beat ClickUp already: field-
level revert on the timeline, agents as first-class assignees in one
vocabulary with people, and per-Center projections of one board rather than a
fork per department.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
Spec §11.2 item 1: a task with a photo of the failed print is the normal case
in a hardware company, and until now there was nowhere to put it.

One file store, not two. gtd_attachments already IS Paca's central files
registry — owner, name, mime, size, path — so the bytes and the upload rules
(size limit, blocked extensions, name sanitising) are IMPORTED from the
capture flow rather than copied. A second table with a second storage
directory would have meant two places to back up, two size limits to keep in
step, and two answers to "is this extension allowed".

What differs is who may READ, and that is the entire reason for the join.
gtd_attachments is owner-scoped end to end: /tasks/attachments/{id}/{name}
serves only to the uploader, which is right for a private capture and useless
for a shared task. pm_task_attachments makes a file readable by anyone who can
see a task it hangs off, so the JOIN carries the access decision and the
personal route is untouched. Two consequences, both security properties rather
than conveniences:

- There is NO attach-by-id endpoint. Upload and attach are one call. A caller
  who could name an arbitrary attachment_id could attach somebody else's
  private capture to a task they own and then read it back through the project
  route — privilege escalation dressed as a feature.
- A personal capture stays unreachable here, because it has no join row.

Detaching keeps the bytes: the same file may hang off another task, and
deleting the row from under it would turn one person's tidy-up into somebody
else's broken link. Detaching something already gone is a no-op, not a 404 —
Paca's lenient-removes lesson, which is what makes a retry safe.

A bug caught before it shipped. The projects BFF proxy re-serialised every
POST as JSON. A multipart upload would have failed req.json(), fallen into the
catch(() => ({})), and reached the gateway WITH NO FILE AT ALL while still
answering 201. The proxy now passes a non-JSON body through byte-for-byte.
workflows_app.md §3.3b documents the identical trap for HMAC-signed webhook
bodies; this was that trap on the upload path.

The migration drops the pm_activities type CHECK by SHAPE rather than by name,
for a reason worth stating: dropping a name that does not exist is a silent
no-op, and the ADD would then succeed under a second name — leaving the old,
narrower CHECK in force alongside the new one. Both would apply, 'attachment'
would still be rejected, and the migration would report success.

A test that was asserting the wrong thing, found by a surviving mutant. The
first traversal test checked the file's path on disk — which is safe BY
CONSTRUCTION (<uuid><suffix>; the supplied name never reaches it), so removing
_safe_name entirely changed nothing it looked at. What that function actually
protects is the stored name, which is echoed into the descriptor, rendered in
the UI, and handed to FileResponse(filename=…) — into a Content-Disposition
header, where separators, quotes and newlines matter. Both properties are now
asserted separately and the mutant dies.

Verification: 374 Python cases across the projects, tasks and org-access
suites; 377 vitest; tsc clean; ruff clean; xenon passes CI's gate. Ten mutants
red and reverted byte-identical, including dropping the viewer scoping on the
serve route and scoping it by file owner instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
@vjvarada vjvarada changed the title WS-27d/e/f: one task store, the "My work" lens, a Projects UI that can create things, and assignment-as-dispatch WS-27 + WS-28: the Projects app becomes usable, the People Center ships, and the Tasks app narrows to personal Aug 6, 2026
@vjvarada
vjvarada merged commit 7e5bcc9 into main Aug 7, 2026
vjvarada added a commit that referenced this pull request Aug 7, 2026
vjvarada added a commit that referenced this pull request Aug 7, 2026
…-cast

fix(WS-28): migration 148 compared name[] to text[] — no migrations have applied since #374 merged
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants