WS-27 + WS-28: the Projects app becomes usable, the People Center ships, and the Tasks app narrows to personal - #374
Merged
Conversation
…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
… 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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follows PR #367 (WS-27a/b/d), which is merged. 12 commits across three workstreams.
What this makes true
/projectswas 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_tasksandgtd_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. Apm_tasknode 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 bystatus_id— a graph carrying one project's UUID could only ever automate that project. Assignment toagent:<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_attachmentsreused, upload rules imported.pm_task_attachmentsis 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 madenameUNIQUE and leftemailunconstrained, which is backwards for a directory that joins on email. Its unnamed consequence — the HR importer upsertsON CONFLICT (name)and would have broken — is handled with asource_key. Neither new constraint can block a deploy: a duplicate address is quarantined intoemail_conflictrather than failingCREATE UNIQUE INDEX, and the status CHECK isNOT VALIDthen validated in a guarded block.WS-28b ships
/people— directory, person page, four panels. Its own feature slug, but the HR projection is imported fromtasks.coreand a test asserts the function's identity. Three filters drop withoutadmin:members:readso search cannot become an oracle for the hidden field, and the response sayshr_visibleso the UI shows "restricted" rather than a blank strip that reads as "nobody filled it in". Load is computed from open assigned tasks and carriesunestimated, 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.
GtdProjectsurvives 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
TaskPanelread 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_valuekeeps an unresolvable{{ref}}as-is at run time by design, and{{trigger.missing}}passesvalidate_graphbecause 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)
tsc --noEmitclean; ruff clean on every new file; xenon passes CI's actual gate.updated_atwas 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
gtd_items. WS-27h owns that retirement.PersonEditorwas 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