Skip to content

WS-27 parity backlog closed · WS-29 multi-tenancy started — see ai-company-brain/HANDOVER.md - #399

Open
vjvarada wants to merge 19 commits into
mainfrom
claude/paca-research-task-management-a1f6zd
Open

WS-27 parity backlog closed · WS-29 multi-tenancy started — see ai-company-brain/HANDOVER.md#399
vjvarada wants to merge 19 commits into
mainfrom
claude/paca-research-task-management-a1f6zd

Conversation

@vjvarada

@vjvarada vjvarada commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

👉 Handing this to another agent? Start at ai-company-brain/HANDOVER.md. Branch state, the two migrations that are on no real database yet, the verification protocol, the ticket queue in dependency order, and every trap that cost real time.

Two workstreams. 17 commits.

WS-27 — the ClickUp parity backlog (project_management_app.md §11.2) is CLOSED

All ten original rows plus the two added since. Tickets o, p, q, r, s, t landed here, on top of a–n already on main.

o Recurring tasks — spawned on close, no scheduler (ADR-028/D6 forbids one)
p Dependencies + subtasks made reachable; blocks cycle guard
s Shared task-card layer between /tasks and /projects
q Calendar — a window, not a page
t Timeline with drawable blocks dependencies
r ⌘K search palette

Two architectural decisions are in the diff, and reviewers should read them before the code: D-PM-11 (hierarchy depth decides what earns a timeline bar, not a reserved Epic type) and D-PM-12 (a dependency arrow warns; it never reschedules — Jira's auto-push was rejected because it contradicts WS-27p's written position and turns one drag into an unbounded cascade of PATCHes). Gantt was a recorded non-goal and is reversed, struck rather than deleted.

WS-29 — multi-tenancy, started deliberately unfinished

Measured, not recalled: 143 app tables, 3 carried a real tenant key, 140 did not. An organization table has existed since migration 130 with one seeded row; tenancy was started and never carried past access control.

  • Migration 158organization_id NOT NULL on all 17 pm_*, plus a parent-consistency trigger (a CHECK cannot read another table). That trigger is why 43 INSERT sites across 16 modules needed no tenant argument.
  • Migration 159UNIQUE (lower(email)), replacing a byte-exact constraint that let one human become two rows in two organizations.
  • S1-1 fixed — the admin plane resolved its tenant from a hard-coded slug across 27 call sites: cross-tenant write into access control.
  • S1-4 fixed — agents acted as whoever ran last (ACB_AGENT_USER_EMAIL process-global, never cleared). A live cross-user bug, not just a future multi-tenant one.
  • A 14-finding leak audit, with a SAFE section and an honest could-not-determine section.

Defects found in already-shipped code

  1. _ and % were live LIKE wildcards on every search — task_id also matched taskXid. Fixed on the shared builder.
  2. WS-27j's deliverable probed only the project-grant closure — assignees without a grant were judged undeliverable, so assignment notified nobody.
  3. overdue's date half was never mirrored in the fake — every overdue test since WS-27k was asserting only the status half.
  4. /assigned-to-me and /my/inbox had no visibility clause at all — by design, since assignment is the claim. Under multi-tenancy, reachable by typing an address, and WS-27e's mirror syncs it into gtd_items.
  5. The extracted deploy script was not shellcheckable — no shebang, so shellcheck exited 1 having checked nothing.

Corrections to my own work, kept visible

Marked ⚠️ in the specs rather than quietly edited: the tenant-scoped table count was 3, not 6 (the CRM's column points at a customer company), and the claim that one-person-one-organization held structurally was false until migration 159.

Verification

2151 backend · 1106 frontend · frontend green in four timezones · tsc, ruff, xenon, theme conformance clean · every ticket driven against a live Postgres 16 with the full migration set applied, which found a bug in every one.

Mutation testing throughout; where a mutant survived, the test was strengthened rather than the pass accepted. Three mutants turned out to be equivalent and the code was simplified instead.

⚠️ Not shipped

The deploy path is broken (WS-25). Merging this does not put it on the box, and neither migration is on any real database.

claude added 2 commits August 7, 2026 19:19
…s one

"Every operations cadence is recurring. Without it those live in someone's head
or in ClickUp."

NO SCHEDULER, AND THAT IS FORCED RATHER THAN CHOSEN. §5's non-goals: "A second
automation engine. ADR-028/D6: /workflows is the only engine; WS-27 contributes
events and node types to it." A recurrence worker here would be exactly that.
So the successor is created WHEN A TASK CLOSES — apply_status_transition
already owns that moment, so a task finished from the board, from My work, from
an automation or from a bulk edit all recur identically. A second call site
would be a fifth way to finish a task that forgets to.

What that costs, stated rather than discovered: a series only advances when
somebody finishes the current one. A monthly report nobody closes does not pile
up twelve copies, which is right; a daily stand-up nobody ticks does not appear
tomorrow, which is the honest limitation. Materialising ahead is already
reachable through the engine that owns scheduling — a cron trigger plus the
pm_task node WS-27f added — so nothing here has to be undone to get it.

THE ANCHOR IS PER RULE, because the two answers mean different things. `due`
keeps the schedule: "stock count on the 1st" stays on the 1st however late the
last one was closed, so the series does not drift. `completed` measures the
interval from when the work was actually done: "water the plants every 3 days"
restarts when you water them. A `due` anchor also CATCHES UP — a monthly task
closed six weeks late would otherwise produce a successor already overdue the
moment it appeared — and the missed occurrences are skipped rather than
backfilled, because nobody wants four copies of a stand-up they did not attend.

THE DATE ARITHMETIC is where this is either right or quietly wrong for a year,
so it is pure and each case is one assertion:

- January 31st monthly: the day is clamped at COMPUTATION time and stored as
  asked. Storing the clamped value permanently demotes the rule to the 28th
  after its first February.
- February 29th yearly: the same shape, once every four years.
- "Every other Monday and Thursday": within a week the rule takes the next
  allowed day, and only jumps `interval` weeks when the week runs out. A naive
  +14 days alternates between the two days instead of giving both days of every
  second week.
- A stand-up at 09:00 stays at 09:00.

Closing a task twice must not spawn twice. A task can cross into `done`
repeatedly — close, reopen to add a note, close again — and every crossing
reaches the same seam. recurrence_spawned_at is the guard, and it is never
cleared: reopening undoes completed_at but does not un-emit a successor that
already exists and may already have been worked on.

Stopping a series keeps the work: the rule is deleted and its tasks detached,
not removed. They are real work, some finished, and a "stop repeating this"
button that swept away three months of completed reports would be the last time
anybody pressed it.

TWO BUGS THE LIVE RUN CAUGHT AND READING COULD NOT:

1. The weekly CHECK passed the very row it existed to reject.
   CHECK (freq <> 'weekly' OR array_length(weekdays, 1) >= 1) looks correct and
   is not: array_length('{}', 1) returns NULL, NULL >= 1 is NULL, and a CHECK
   only FAILS on false. A weekly rule with no weekdays inserted happily.
   coalesce(…, 0) fixes it, and a test asserts the coalesce is present because
   the hermetic suite has no database to try the expression on.
2. _next_number and _default_status were reimplementations, and one invented a
   column (last_number; the real one is last_value). Both replaced by core's own
   next_task_number and load_default_status — the same mistake WS-27n had just
   been careful to avoid, made two tickets later in the same package.

A third, caught by its own test: int(rule.get("interval") or 1) turns an
explicit 0 into "every 1" — a typo that looks exactly like a save, and one the
database's CHECK would then have refused as a 500 rather than a 422.

In the browser the SENTENCE is the feature. A form of five controls is a shape;
"Every 2 weeks on Mon, Thu, keeping to the schedule" is something somebody can
check before committing to it — shown live rather than on save, because picking
the wrong anchor is invisible until a cadence has drifted for three months. The
occurrence limit reads as what is LEFT rather than the cap, and switching
frequency clears the fields the new one does not use so a stale day_of_month
cannot reappear.

45 hermetic + 27 vitest cases, 31 mutants killed and reverted byte-identical,
39 checks against a real Postgres.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
…e nobody had written down

"pm_task_links and parent_task_id both exist, unreachable from the board. Data
with no surface is a promise the product does not keep."

GET /projects/tasks/{id}/relations, lib/relations.ts, and a relations block in
the task panel. No migration — the tables have been right since 146.

BOTH HALVES WERE UNREACHABLE, AND FOR DIFFERENT REASONS. Links could be created
and deleted since WS-27a but never LISTED: get_task returns a `links` COUNT and
nothing else, so no client could draw one. Subtasks could be created from the
panel but never listed either — ?parent_task_id= has existed on the list
endpoint since WS-27a and nothing called it.

THE RULE NOBODY HAD WRITTEN DOWN: `blocks` may not form a cycle.
assert_no_task_cycle has guarded parent_task_id since WS-27a, and the identical
hazard sat unguarded on links the whole time. A blocks B blocks C blocks A is a
deadlock no human can resolve by finishing something, and every walk over it
runs forever. assert_no_block_cycle closes it, bounded by the same MAX_DEPTH its
sibling uses, and it TRACKS WHAT IT HAS SEEN — data can already contain a loop,
since every link created before the guard existed went in unchecked, and the
walk has to terminate over one rather than spin.

Only `blocks` is guarded. A cycle in relates_to or duplicates is redundant, not
harmful, and refusing one would be a rule with no failure to prevent.

BLOCKED-NESS IS DERIVED AND SHOWN, NEVER ENFORCED. A task is blocked when
something that blocks it is still open, so a blocker reaching `done` makes the
section go quiet — that is how you learn you can start. Refusing to CLOSE a
blocked task is the obvious next step and is deliberately not taken:
dependencies in a real workspace are frequently approximate, and a tool that
will not let somebody finish work they have finished is a tool they route
around — after which the links stop being maintained and the feature is worse
than absent.

VISIBILITY IS APPLIED TO THE CHILDREN, not inherited from the parent. A subtask
can be moved into a project the reader cannot see, and listing it because its
parent is readable would disclose a title from behind a grant. The live run
asserts both that it is absent and that its title does not appear.

One endpoint carries both directions, because `blocks` outgoing means "this
holds those up" and incoming means "this is waiting" — a client given one side
would have to ask twice and would still not know which was which. Blocked by is
shown first, since it is the only section that changes what somebody should do
next, and empty sections are dropped: six empty headings on every task is how a
panel becomes something people scroll past.

Progress counts the status CATEGORY rather than completed_at, for the same
reason everything else in this app does — a project can name its finished lane
"Shipped" or "Signed off", and `cancelled` counts as resolved even though
nothing was completed. It reads as "1 of 3" rather than a percentage, because
33% is a worse answer than "1 of 3" to the question people are asking.

21 hermetic + 16 vitest cases, 11 mutants killed and reverted byte-identical,
19 checks against a real Postgres including a subtask and a link in a project
the reader has no grant on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
@vjvarada vjvarada changed the title feat(WS-27o): recurring tasks — no scheduler, because the spec forbids one feat(WS-27o + WS-27p): recurring tasks · dependencies and subtasks made reachable Aug 7, 2026
"the UI, kanban, task cards etc can be taken from the tasks app right?"

Familiar, yes. Taken, no. /tasks' TaskCard is 395 lines bound to
useTaskStore and to GtdItem's own fields — energy, deepWork, disposition —
none of which pm_tasks has or should grow, and D-PM-6 retires gtd_items at
WS-27h, which would take the Projects board with it. What moves is the
VOCABULARY: @/lib/taskCard decides which chips a task earns, what counts as
overdue and what an avatar's letters are; @/components/TaskMeta is the one
file that turns a tone name into a colour. Neither knows about a store.

A card can only show what the list endpoint returns, and it was returning
almost nothing. Links and parent_task_id have been readable since WS-27p —
one task at a time. A board draws them on every card at once, so most of
this is backend: two aggregates over the page's ids fill subtasks
{done,total} and blocked_by_count on every row. Per card that is N+1 across
an imported workspace, and at three-task scale it looks identical.

A finished blocker does not block, and the count says so in SQL rather than
after — a card still marked blocked after its dependency shipped is a card
people learn to ignore. Archived subtasks leave the denominator for the
matching reason: counted, "2/3" could never reach 3/3.

A zero earns no chip; chip order is fixed so the row is scanned, not read.
Overdue changes the icon as well as the tone, so the signal survives a
reader who cannot tell muted from destructive.

One behaviour change outside Projects: /tasks' isOverdue checked only the
date, painting every completed task with a past due date red forever.
Sharing the function fixed that side too.

The fake needed teaching, as it did for WS-27n, and the same lesson applied:
every clause is mirrored only when the statement carries it, and which end
of a `blocks` link is the blocked one is read off the SQL rather than
assumed — a mirror that filters unconditionally agrees with itself no matter
what the route stops emitting.

Verified: 326 backend + 988 frontend tests; ruff and xenon clean; theme
conformance green; next build clean; seven mutants killed (two behavioural
kills for the SQL clauses, not only structural); one equivalent mutant found
and removed along with the test that asserted nothing; live Postgres 16 run
against real endpoint functions all green.

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
…on number somebody else had claimed

Diff-review repair round. Both P1s needed a cursor that had actually been
running to see, which is why the first cut passed its own seven mutants.

P1-1 — activated_at guarded the wrong thing. It stops a deep resync, and it does
nothing at all about a flag that was on, turned off for four weeks, and turned
back on: the cursor still carried day-1's anchor, so the first ON cycle minted
the entire OFF window in one batch. The reviewer measured 27 of 27, each born
zoho_dirty and each pushing unattended into the live tenant. The anchor now means
the CURRENT ON epoch: a gap in the step's own runs beyond REANCHOR_GAP_SECONDS
(3600, six scheduler periods) re-stamps the cursor, mints nothing from the gap,
and says so at WARNING with gap_seconds. Fail-closed both ways — an OFF window
and a real outage each skip their backlog, because a missed lead is
hand-creatable and visible in the mailbox and 27 unattended tenant pushes are
neither.

⚠️ DEVIATION, with its reason: dormancy is measured on a THIRD column,
last_run_at, not on processed_watermark as ruled. The watermark tracks MAIL, not
runs, and the literal predicate has two production failures. A mailbox that is
merely quiet over a weekend carries a 60-hour-old watermark while the step has
run faithfully every 600s — it would be re-anchored, and Monday's first message,
the one this whole feature exists to catch, falls before the new anchor and mints
nothing. Every Monday. And a poison head message holds the watermark still ON
PURPOSE under P1-2 below; a watermark-based test would re-anchor past it after an
hour and quietly undo the stall that was supposed to stay visible. last_run_at is
stamped on every cycle including empty and stalled ones, so "quiet" and "not
running" stay different facts. The ruling's constant, log key and behaviour on a
genuine OFF window are unchanged. Both named tests are in the file, including the
control that a quiet-but-running mailbox is never re-anchored.

P1-2 — a failure advanced the cursor over the work it lost. This step opens a
SECOND session per lead through create_record while holding the batch's own, so
pool exhaustion fails many candidates at once; the old code advanced past all of
them. Measured: 3 candidates, 3 errors, watermark advanced, three leads gone for
good. The watermark now advances over the contiguous PREFIX that actually wrote
its leads and stops at the first that raised. Later successes in the same batch
are simply re-considered next cycle, which is free — the third unknown-sender
step finds the lead they already created. A held cursor logs
sync.auto_lead_stalled at WARNING EVERY cycle, because a held cursor and a quiet
mailbox both create nothing and only the level can tell them apart; that trades
silent loss for a visible stall on a genuinely poison head message, deliberately,
fail-closed toward the CRM.

The counter-case matters as much: a failed first ACTIVITY does NOT hold the
cursor. The lead is already committed, so the message would be skipped on
re-consideration and the activity never retried — holding for it would stall
forever on work that cannot be redone. It is counted separately as
activity_errors, and `created` now increments the moment the lead commits rather
than after its activity: the row exists and will push either way, and a cycle
logging created=0 beside a queued lead sends whoever reads it looking in the
wrong place.

P2-4 — the Sent probe inherited @>'s case-exactness. The owner wrote to
Asha@AcmeRobotics.com; she replied from asha@acmerobotics.com and was minted as a
cold lead. This module now asks with EXISTS over jsonb_array_elements and
lower(). _maybe_block_cold is left alone on purpose: it is the email package's
predicate with its own blast radius. The fake learned to READ the new shape — and
the hard part was stripping it, since its inner lower(recipient->>'email') =
:address is exactly what _JSONB_LOWER_CMP matches, which would have filtered the
outer rows on a column they do not have.

P2-6 — cfo@mail.fracktal.in passed both colleague gates. Gate 2 is now suffix
aware, anchored on a leading dot so notfracktal.in is still a prospect.

P2-5 — the cap had no tiebreak. ORDER BY rules_processed_at, id; fetch cap+1; and
when the cap falls INSIDE a group sharing one timestamp, defer the whole group.
Only then — deferring the last group unconditionally would shrink every capped
batch by one message for nothing. Latent in production, where the rules runner
stamps one transaction per message.

Also: lead_name and the activity subject are clipped (120/500, with a marker).
Nothing upstream bounds a display name, and it becomes a column every list, board
card and Zoho push carries.

MIGRATION RENUMBERED 157 → 158. Open PR #399 claims 157, and two migrations
sharing a number replay in filename order against the wrong schema. The ladder
carries a deliberate reservation gap until #399 lands; the header names it, the
test finds the file by content, and the contiguity assertion is replaced by the
uniqueness one — which is the property that actually protects the ladder and the
only one this branch can hold.

The step's import moved inside the gated branch so the OFF state does not load
routes/crm on the mail path. The predicate stays above the gate on purpose:
auto_lead_enabled is the flag's ONE definition, and reading settings.crm_auto_lead
in the hook would make two places responsible for agreeing what the flag means.
The import-inside-try divergence from the five sibling steps is now recorded
rather than left to be rediscovered.

52 → 73 tests; 7 → 13 mutants red and reverted, one of them precision-checked
(reverting the Sent probe must not redden the lower-case already-emailed case, or
the mutant broke it rather than narrowing it). _crm_fakes gained fail_on(after=N),
because where in a batch a failure lands IS the property under test.

Still BUILT, NOT FLIPPED, NOT DEPLOYED. R4 in the same change: the ticket's cursor
paragraph rewritten to the epoch-anchor semantics, done-when 8 and 9 added, the
as-built block's deviation list grown to seven, plus work_plan's WS-26 row and
§6(b) — which now tells an owner that turning the flag off is a stop, not a pause
that accumulates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
claude added 4 commits August 8, 2026 04:24
The ninth backlog row, and the first view that cannot be paginated. A month
with ninety tasks read at page_size=50 draws forty and leaves the other days
looking EMPTY. A short page announces itself; a short month does not, and
nobody investigates a quiet week. So GET /projects/calendar takes a window,
returns everything in it, and says `truncated` when the cap is reached
rather than handing back a plausible-looking month.

start_date has existed since migration 146 with no surface — the same
complaint §11.14 makes about links, and the reason the calendar was the view
worth building: a task is a bar from its start to its due date, not a dot.

Overlap, not equality. A task starting Monday and due Friday belongs on
Wednesday's cell; `due_at BETWEEN` puts it on Friday alone, which is exactly
the week somebody looks at Wednesday and concludes they are free. A task
with NEITHER date falls out through NULL — correct and invisible — so
`undated` counts them with the same filters and the view says so.

The window is read in UTC and the client asks for a day of slack. A
start_date is a floating date and a due_at is an instant; no single frame
makes both exact, so the server over-selects and the browser places.
start_date is anchored with AT TIME ZONE 'UTC' rather than CAST(… AS
timestamptz), which would silently read the connection's TimeZone — pinned
by a live run with the session set to America/Los_Angeles.

Filters carry across the switch. FastAPI ignores an unknown query parameter,
so a filter the board sends and the calendar does not declare is not an
error — it is a filter that quietly stops applying, which reads as the
filter breaking. A test asserts the calendar's parameter set covers the
list's minus a named, reasoned exclusion list. due_before is excluded
(it bounds the same column as the window); overdue is not, because
"already late" is a fact about the status as much as the date.

No second write path: dragging a card is PATCH /tasks/{id}. Dragging a bar
moves the whole bar and keeps its time of day — writing only the dropped
date leaves the other end behind and inverts the interval the moment you
drag left.

new Date("2026-08-07") is midnight UTC, the 6th west of Greenwich, so the
grid works in YYYY-MM-DD keys throughout. That claim is only behaviourally
testable west of Greenwich, so the suite runs in four timezones AND pins the
rule structurally, because CI runs in one.

Building it found a hole in the fake: overdue's date half (due_at < now())
had never been mirrored, so every overdue test since WS-27k was asserting
only the status half and would have passed with the comparison deleted.

Verified: 605 backend + 1025 frontend tests; calendar grid green in UTC,
Asia/Kolkata, America/Los_Angeles and Pacific/Kiritimati; ruff and xenon
clean; theme conformance green; next build clean; 14 mutants killed across
both halves, one equivalent mutant found and the code simplified rather than
the test kept; live Postgres 16 run all green — and it caught a wrong
assertion of mine about sort order before it reached the suite.

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

Owner asked for a timeline view where tasks and subtasks can be made
dependent on each other by wiring them together, Jira/ClickUp style, and
whether Paca has UI to take.

Three findings, written up rather than acted on:

1. We do not have a timeline. Backlog row 9 was "Calendar / timeline view"
   and WS-27q built the calendar half; closing the whole row was wrong. A
   month grid answers "what is due when"; bars on a continuous axis answer
   "what runs alongside what". Row split — 9a closed, 9b open — and §11.16
   now says so at the top rather than reading as if both shipped.

2. Gantt was a recorded non-goal. Reversed, struck rather than deleted,
   because the reversal is the interesting part: the original note treated
   Gantt as decoration, which is true of the chart and false of what was
   actually asked for — a surface where a dependency is DRAWN rather than
   typed. pm_task_links has been cycle-guarded since WS-27p and reachable
   only through a dropdown and a task number.

3. Paca has the chart and not the wiring. roadmap-view.tsx (438 lines,
   Apache-2.0) is a real Gantt worth taking geometry from — sticky task
   column, PX_PER_DAY, month cells, today line, undated tasks listed and
   unbarred. It draws no dependency arrows (zero matches for
   arrow/svg/path/depend) and is entirely read-only (zero for drag/resize).
   So Paca answers the layout; the wiring is ours to design.

WS-27t is minted with a table of what is already built (all of it — dates,
links, cycle guard, both-direction read, blocked counts, overlap query,
the PATCH write path), so the ticket is scoped honestly as rendering and
gesture rather than schema.

Two decisions, and only one is agent-proposable:

- D-PM-11 — what earns a bar. Proposes hierarchy depth over Paca's reserved
  "Epic" type, because inventing a reserved type name to make a chart
  legible is a data-model change in service of a rendering problem, and
  D-PM-2 put types in each project's hands deliberately.

- D-PM-12 — does an arrow constrain the schedule or only describe it.
  OWNER-ANSWER REQUIRED, and registered as WS-27 owner gate (e). It changes
  what data already in the database means: WS-27p decided in writing that
  blocked-ness is derived and shown, never enforced, and Jira's auto-push is
  the opposite — adopting it strikes that paragraph rather than extending it,
  and turns one drag into an unbounded cascade of real PATCHes each carrying
  a field_change activity and a notification. Recommendation on the record
  (warn, do not push); all three options and their costs written out.

No code. WS-27t does not start until D-PM-12 is answered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
"a timeline view that can also make tasks and subtasks dependent on each
other, with wiring them to each other, similar to how it works on Jira and
ClickUp." Two things, and the second is the one that matters — a Gantt with
no dependency gesture is decoration, which is why Gantt was a non-goal
until it was asked for.

Almost none of this is schema work. Dates, links, the cycle guard, the
both-direction read, blocked counts and the overlap window all shipped in
WS-27a/p/q/s. This is one aggregate, some geometry, and a gesture.

The window is the resource, so there is no /projects/timeline: the calendar
endpoint grew include_links, and calendar and timeline are two renderings of
one question — the rule §11.8 states for list and board, a third time. An
edge is returned only when BOTH ends are in the window, because an arrow
needs two bars; the edge to an off-window blocker survives as the
blocked_by_count badge, which is the honest rendering of "something you
cannot see is holding this up".

D-PM-11 — hierarchy depth decides what earns a bar. A parent with no dates
borrows its children's span, marked derived and drawn dashed, or the default
view is blank for the projects that use subtasks properly. A subtask whose
parent is off-window is promoted, not hidden — hiding it is how a filtered
timeline silently drops work. Paca's reserved "Epic" type was rejected:
pm_task_types is per-project data with no reserved names (D-PM-2), so it
would need a seeded row or a name-match that breaks on rename.

D-PM-12 — an arrow WARNS, never reschedules. The owner delegated the choice;
chosen deliberately over Jira's auto-push. The useful half of a dependency
is knowing two tasks now disagree. Auto-push delivers that and also silently
rewrites other people's dates — a cascade landing in the activity spine as
dozens of field_change rows and notifications with no single act to point
at, and the first time a negotiated date moves untouched, people stop
trusting the dates. WS-27p's "derived and shown, never enforced" therefore
stands unamended. Three sub-rules keep the warning worth reading: equal
dates are not a conflict (a handover), a missing date is not a conflict
(unknowable), and a finished blocker never conflicts.

One rule, two surfaces: conflicts() is pure, the timeline colours arrows
with it and RelationsBlock writes its sentence with it — which is why
/relations grew start_date and due_at. The cycle check is NOT duplicated in
the browser; canLink refuses only self-links and duplicates and lets a→b
after b→a through so assert_no_block_cycle answers with its own message.

Paca gave the layout (sticky column, PX_PER_DAY, month cells, today line,
undated listed and unbarred) and nothing else — it draws no arrows and is
read-only, so the gesture is ours.

Two rounding traps: dayPx rounds because a DST-straddling range is 23 or 25
hours across the transition and an unrounded quotient is permanently a
fraction of a day off — only catchable in a DST zone, so pinned
structurally too. And a bar covers its last day rather than stopping at that
day's left edge, or every span is a day short.

Found while building: the fake's mirror of the edge query hard-coded which
column was the blocker, so a mutant swapping the SQL's aliases — every arrow
backwards — passed the whole suite. It now reads roles and membership tests
off the statement; both mutants die behaviourally.

Verified: 611 backend + 1074 frontend tests, the frontend green in UTC,
America/Los_Angeles, Asia/Kolkata and Pacific/Kiritimati; ruff and xenon
clean; theme conformance green; next build clean; 17 mutants killed across
both halves, one survivor fixed by strengthening the test rather than
accepting it, one over-broad structural assertion narrowed after it tripped
on the module's own prose; live Postgres 16 run all green including that an
edge from an ungranted task is not drawn while its badge still counts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
The last row of the parity backlog. Both halves of "?q= exists on the list
endpoint; there is no search surface" were true, and the second was worse
than advertised.

`_` and `%` were live wildcards on every search anybody had done.
build_task_filters bound %{q}% raw, so `_` — LIKE's single-character
wildcard — meant `task_id` also matched `taskXid`, and `50%` quietly meant
`50`. In a workspace where people search for identifiers all day that is a
steady drip of hits nobody asked for, and it reads as fuzzy matching rather
than as a bug. like_escape fixes it on the SHARED builder, so the board and
every saved view get the fix — patching only the new endpoint would leave
the bug where people actually meet it.

Why a second endpoint, having twice argued against one: the list answers
"which tasks match these filters, in this order, on this page"; search
answers "what did you mean". It ranks, and TASK_SORTS is a column allowlist
that deliberately cannot express relevance. It is capped, not paged —
nobody pages through search results, and page 2 of a relevance ordering is
where relevance ran out. And it names the project. What decides what a
caller may SEE is still shared, so search can never surface what the list
would hide; that is the part that must not be duplicated.

Ranking is in SQL before the LIMIT: ranked afterwards over a capped set, the
best answer is only present if it was already in the arbitrary fifty rows
returned — a defect that presents as "search is bad at long queries".

The palette is ⌘K, not a page: the question is "where is that task", asked
while doing something else. Four rules that only break under real typing
speed live in lib/search.ts as pure functions — "no results" is never
claimed while a request is in flight, a stale response cannot win, the
arrows belong to the palette unless a modifier is held, and the highlight
needle is escaped before it becomes a regex (the browser twin of the very
defect this ticket fixed on the server).

Found by the live run and invisible to all 43 hermetic tests: `:number IS
NOT NULL` names no column, so Postgres cannot infer the parameter's type and
asyncpg answers AmbiguousParameterError — the query never runs. A Python
fake has no type system to be ambiguous about. Fixed with an explicit
CAST(:number AS bigint) and pinned structurally, the only level at which a
hermetic suite can hold it.

The fake also learned to read LIKE properly: like_to_regex translates %, _
and the backslash escape rather than doing a substring match. A mirror that
treated the pattern as a literal would have agreed with both the escaped and
unescaped implementation, making the defect invisible to the suite that
exists to catch it.

Input gains a `ref` prop (React 19 passes it as an ordinary prop, so no
forwardRef wrapper) — a palette you have to click into is one you stop using.

With this the ClickUp parity backlog in §11.2 is closed: all ten rows plus
the two added since.

Verified: 655 backend + 1106 frontend tests; ruff and xenon clean; theme
conformance green; next build clean; 20 mutants killed across both halves
with none surviving; live Postgres 16 run all green, including that the list
endpoint's own q is now escaped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
@vjvarada vjvarada changed the title feat(WS-27o + WS-27p): recurring tasks · dependencies and subtasks made reachable feat(WS-27o…t): recurrence · dependencies · cards · calendar · timeline · search — parity backlog closed Aug 8, 2026
claude added 10 commits August 8, 2026 14:39
…chet

Owner is planning migrations for a multi-tenant CommandCenter and asked that
current work take it into account. Measured the blast radius rather than
guessing, and it changes what should happen next.

MEASURED, off the migration tree and checked against a live Postgres 16:
143 app tables, SIX carry organization_id (app_user, crm_activities,
crm_contacts, crm_deals, org_group, org_role), 137 carry none — including
all seventeen pm_*. An `organization` table has existed since migration 130
with one seeded row (slug='default') and app_user.organization_id. Tenancy
was started and never carried past access control and the CRM; org_settings
says so in its own comment.

So this is not a Projects problem. WS-27 is 17 of the 137, alongside every
gtd_*, email_*, wa_*, workflow*, app*, chat_* table.

The number that decides the cost: app_user.email is globally UNIQUE, so
today one person = one organization structurally. D-MT-1 asks whether that
stays true. If yes, the tenant is derivable from X-User-Email and the
identity seam the whole platform is built on does not change shape. If no,
UNIQUE(email) becomes UNIQUE(organization_id, email), every request needs a
tenant discriminator, and bare emails stop identifying a person — which
reopens D-PM-4's grant and assignee vocabulary. That is a product question
about how the SaaS is sold, so it is owner-answer; the recommendation on
record is (a) for v1, relaxable later behind an org-switcher.

Projects is cheaper to retrofit than its size suggests: 128 FROM/JOIN
references to pm_* across 16 modules, but ONE closure query. So it is a
column on 17 tables, a predicate in one query, and one line in Visibility —
contained, and contained only until real data lands.

Which is why this blocks something already queued: the production ClickUp
import (§6 gate (a)) is not wrong, it is EARLY. Running it now writes a real
workspace into 17 unscoped tables and turns a one-line default on empty
tables into a backfill on live rows. Registered on the gate itself, since
that is where somebody will read it.

Ships one piece of code — tests/unit/test_tenancy_boundary.py — a ratchet on
the conformance.test.ts model: the 137 are frozen, a NEW table without
organization_id fails, and a baselined table that GAINED one fails until it
leaves the baseline so the figure never quietly becomes fiction. It reads
ALTER TABLE ... ADD COLUMN too, which is how app_user got its key and which
a CREATE-only scan reports as unscoped — it did, in the first version, and
the parser was checked against the live schema before this was written. Its
job is not to demand the retrofit; it is to stop the number growing while
D-MT-1 is answered.

Verified: the ratchet bites on all three cases (new unscoped table, fixed
baselined table, stale baseline entry) and passes a new correctly-scoped
table — which the first version wrongly failed, so that assertion was
replaced with one that checks something real. 661 backend tests, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
Put to the owner with both options costed; the answer was "go ahead with
what you think is right", so the recommendation was taken as the decision.

ANSWERED: (a) — app_user.email stays globally UNIQUE, and a request's tenant
is derived from X-User-Email via app_user.organization_id. No app's auth
seam changes shape.

Taken rather than left open because it is the REVERSIBLE direction. (a)→(b)
is a migration plus an org-switcher, run once, while accounts are few.
(b)→(a) takes a capability away from people already relying on it. Given a
delegated choice between a door that stays open and one that closes, the
open one wins.

The trigger to revisit is named rather than vague: the first customer who
needs one human in two organizations — an agency or a consultancy. Deciding
it then costs a migration; discovering it after that tenant onboards costs
their trust.

Unblocks WS-29a, which is what gates the production ClickUp import.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
⚠️ I dispatched this against a stale premise and the agent corrected it.
D1's extraction was already committed at c1eba71 — `DEPLOY_SCRIPT` appears
zero times in deploy.yml, and spec §8.1 says so. I read the WS-25 work-plan
row and §3, both of which describe the problem state, and did not read §8.
The right response was to verify rather than redo, which is what happened:
the pre-extraction env: value was reconstructed by YAML-parsing the old
deploy.yml and diffed against scripts/vps_apply.sh — byte-identical, 437
lines, sha256 a779724d089319f6.

What was NOT delivered, and is here:

The extracted script was not shellcheckable, which is half of what D1 was
for. Line 1 was `set -e`, because a YAML env: value fed to `bash -s` has no
shell to declare — so shellcheck refused to analyse it at all (SC2148,
error, exit 1, nothing checked). It was the only .sh in scripts/ without a
shebang, a direct artifact of the extraction. Verified by stashing the fix
and watching shellcheck go blind again.

With a shebang it runs, and reported exactly one real finding: an unquoted
`$(date +%s)` and `$deadline` in the healthcheck wait loop (SC2046). Fixed.
`shellcheck scripts/vps_apply.sh scripts/vps_pull.sh` now exits 0 at default
severity. `--enable=all` adds ~700 opt-in style notes, declined: rewriting
481 lines of production deploy logic for zero defect is the behaviour change
this ticket exists not to make.

The file stays non-executable (0644, matching vps_pull.sh). Both callers
name the interpreter; +x would advertise a fourth way to start a deploy.

And the spec gains a MEASURED table where it had a claim. The self-rewrite
hazard was demonstrated, not asserted, against a throwaway repo — and it is
worse than §3 described. The naive single-stage script fails three ways, and
"executes garbage" is the RAREST:

  rename (what git reset --hard actually does): the fd keeps the old inode,
    all 12 steps run — v1's steps against a v2 tree — and it exits 0
  in-place, shorter: resumes past EOF, remaining steps silently vanish,
    exits 0
  in-place, shifted: resumes mid-token, `iet: command not found`, exits 127

Two of three exit 0, and git's own method is the quietest: HEAD correct,
steps stale, nothing red. No exit-code check and no health probe catches it.
The two-stage bootstrap was proven against the REAL scripts/vps_pull.sh, not
an imitation: v2's twelve steps, in order, exit 0, markers written. I
re-ran the harness myself rather than take the report on trust.

Left for the owner, deliberately: all four WS-25 acceptance items remain
OWNER-GATE, nothing was switched, and the push path still delivers by
`bash -s` from stdin. Reported but not changed: ENV_FILE/WB_ENV are
hardcoded to /opt/acb/app while APP_DIR is overridable, so a hand-run with
APP_DIR=/sandbox would git-reset one tree and write secrets into the live
box's .env — documented in place, since D1's whole point is that this file
can now be hand-run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
⚠️ CORRECTION FIRST. This spec, the work plan and a commit message all said
SIX tables carry organization_id. It is THREE. `crm_activities`,
`crm_contacts` and `crm_deals` carry a column spelled `organization_id` that
REFERENCES crm_organizations — a customer company, not the tenant root.
Verified against pg_constraint on a live database. The CRM is unscoped like
everything else, so the real figure was 140 unscoped, not 137.

Two consequences, and the second is worse than the miscount:

  * the column name is TAKEN. Scoping crm_* needs a rename or a different
    name, and that has to be decided before WS-29d touches those tables.
  * test_tenancy_boundary.py — which I wrote and shipped two commits ago —
    matched on the column NAME. It counted the homonyms as scoped, which
    means any future table with an `organization_id` pointing anywhere at
    all would have passed the ratchet silently. A guard satisfiable by a
    coincidence of naming is not a guard. Fix lands with WS-29a, which
    currently holds that file.

THE AUDIT. Four S1 findings, all of them paths a column-plus-predicate
retrofit does not close — because the tenant now lives on `Visibility`, and
every one of these is a path that never builds one:

  1. The entire admin plane resolves its tenant from a hard-coded slug.
     get_org_id() does `WHERE slug = 'default'` and ignores the caller;
     26 call sites — members, roles, groups, permission overrides, access
     requests, /auth/me. A tenant-B admin lists, invites into and grants
     roles in tenant `default`. Cross-tenant WRITE into access control, by a
     correctly-authorised caller. Confirmed by reading _common.py myself.
  2. One credential set for the deployment. provider_keys.provider is the
     PRIMARY KEY, and migration 11 put Zoho/ClickUp/Gmail tokens in the same
     table; reads go through a module singleton keyed by provider alone,
     writes go to os.environ and the on-disk .env, which cannot be
     tenant-scoped in a shared process. LiteLLM's own organization_id is an
     UNRELATED namespace — do not connect them.
  3. The event bus is global and the receiving workflow can write any task.
     Tenant A edits a task, tenant B's workflow fires, and it patches tenant
     A's task. Read and write, closed by nothing in the retrofit.
  4. Agent tool identity is a process-global env var that is never cleared,
     so an agent can act as whoever ran last. Under D-MT-1 that string IS
     the tenant — and it is already a cross-USER bug today, not merely a
     future multi-tenant one.

SAFE, with reasons, which is worth as much as the leaks: there is no object
storage at all (no S3/MinIO/presigned URLs anywhere in first-party code —
attachments are local disk, uuid4-named, never served by path), /projects/
search cannot be widened from the query string, pm_task_counters is
per-root-project, and notifications resolve each recipient's own authority.
Email and WhatsApp scope on user_id and are safe ONLY because D-MT-1 makes
email globally unique — a cost that now belongs in D-MT-1's write-up.

The audit also proposes a THREE-way baseline split over my two, and the
argument is right: NEVER_SCOPED / DEPLOYMENT_GLOBAL / NOT_YET_SCOPED,
because "this is deliberately global" is a decision and hiding it among
"not done yet" is how it gets made by accident.

Also generalises the import warning: INGESTION_CONSUMER=1 and
CRM_ZOHO_SYNC=1 write unscoped rows UNATTENDED and are one env var away
from doing so. The ClickUp import is merely the one with a button.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
⚠️ INTERMEDIATE COMMIT. The suite is green and the tree is coherent, but
this ticket is NOT finished and must not be read as such. Committed because
the working tree cannot be left dirty across turns, not because the work is
complete.

What is verified, by me, against a live Postgres 16 rather than taken on
trust:

  * migration 158 applied — all 17 pm_* tables carry organization_id, all 17
    NOT NULL, and every one REFERENCES organization(id), confirmed by
    reading pg_constraint (not by reading the migration).
  * core.py carries the tenant: Visibility.organization_id, a
    _TENANT_PROJECTS_SQL for the unrestricted (`data:org:read`) path, and
    `g.organization_id = CAST(:vis_org AS uuid)` inside the grant closure —
    which is the line that makes `subject = 'org'` mean "everybody IN THIS
    ORGANIZATION" rather than everybody. It is flagged in place as the most
    dangerous line in the retrofit, which it is.
  * the ratchet moved the 17 into EXPECTED_SCOPED and dropped
    BASELINE_UNSCOPED from 137 to 120, which is the ratchet working as
    designed rather than being edited around.
  * 661 tests pass, from 45 failed / 611 passed an hour ago.

What is NOT done, and why this is `wip`:

  * 661 is EXACTLY the pre-tenancy count. No two-tenant isolation test has
    been added yet — and that is the single most important thing this ticket
    owes. A tenant boundary with no test proving a second tenant cannot see
    the first is a boundary nobody has checked.
  * no live-Postgres two-tenant run yet. Every ticket this session has had a
    bug that only the live run found; there is no reason to think this one
    is the exception, and this is the ticket where the bug would be a leak.
  * EXPECTED_SCOPED still contains crm_activities/crm_contacts/crm_deals,
    which the leak audit proved are NOT tenant-scoped — their
    organization_id references crm_organizations, a customer company. That
    is my defect (the ratchet matches column NAME, not FK target) and it is
    fixed next, once the agent releases the file.

The agent is still running. I will not call this done until the isolation
proof exists, the live run is clean, and the homonym bug is out.

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

Supersedes c52c34d's `wip`. That commit was mine, made to satisfy a
repo-hygiene hook mid-build; the agent doing the work reasonably flagged it
as an unexplained third-party commit. Labelled NOT YET DONE at the time
because it wasn't. It is now.

WS-29a — organization_id NOT NULL REFERENCES organization(id) on all 17
pm_* tables, idempotent throughout. ⚠️ The "empty tables" premise in my
brief was WRONG: the live database held 10 pm_tasks and 2 pm_projects of
fixture residue from WS-27's own live runs, and SET NOT NULL does not care
where a row came from. Backfilled to slug='default' first, and a missing
default org fails the deploy loudly rather than guessing.

The parent-consistency guard is a TRIGGER, not a CHECK, because a CHECK
cannot read another table — Postgres refuses the subquery. One generic
function with 21 attachments: it FILLS a NULL from the parent and REFUSES a
mismatch. I tested both halves myself against live Postgres rather than
taking them on trust: a pm_tasks row claiming org B under org A's project is
refused with a message naming both organizations, and a NULL is filled from
the parent. That trigger is the load-bearing design decision — it is why 43
INSERT sites across 16 modules did not have to grow a tenant argument. The
app decides the tenant in exactly two places; the database derives the rest.

WS-29b — Visibility carries organization_id, resolved from X-User-Email
BEFORE the data:org:read check, and `vis_org` is always bound so None fails
closed by SQL semantics rather than by an `if`.

THE LEAK THE LIVE RUN FOUND, which the hermetic suite structurally could
not: /projects/assigned-to-me and /projects/my/inbox have no visibility
clause AT ALL — deliberately, because assignment IS the claim, so there was
no grant clause to notice was missing and every grant-shaped test was
already green. Under multi-tenancy that makes them reachable by typing an
address: anyone in org B puts a member of org A on their task and its title,
description and dates appear in that person's list. Worse than a read —
WS-27e's personal mirror SYNCS /assigned-to-me into gtd_items, so the leak
would be copied into a second app and outlive the request. Both now carry
the tenant; the grant clause stays absent by design.

Two more: attachments' `if not vis.unrestricted:` was correct while the
unrestricted clause was TRUE and a whole-database leak once it became the
tenant; and both importers matched an existing department by lower(name)
with no tenant, which is a cross-tenant WRITE — org B's whole workspace
poured into org A's department.

18/18 mutants killed, reverts byte-identical. Three survived the first pass
and were fixed rather than accepted, two from one instructive class: the
test regexes used a single space where the migration column-aligns, so
`UPDATE pm_\w+ SET` matched exactly 1 of 17 statements. The tests now assert
the match COUNT equals the table count.

AND MY OWN DEFECT, found by the leak audit. test_tenancy_boundary.py
matched the column NAME, so it counted crm_activities/crm_contacts/crm_deals
as tenant-scoped on the strength of an organization_id that REFERENCES
crm_organizations — a customer company. A guard satisfiable by a coincidence
of naming is not a guard: any future table with an organization_id pointing
anywhere would have passed silently. It now matches the foreign key's
TARGET, and a probe table referencing crm_organizations is correctly
refused. The three move into the baseline where they belong, so the count
is 137 → 123 (−17 pm_*, +3 homonyms) and the debt was understated, not over.

Verified by me, not reported to me: 1673 backend tests pass (775 in the
projects/tenancy slice, from 661), 1106 frontend, ruff and xenon clean, the
two-tenant live run re-executed here and green across 40 assertions
including 404 — never 403 — for another tenant's task under data:org:read.

Recorded, not smuggled in: clickup_id stays globally UNIQUE, so a second org
importing the same ClickUp workspace now fails loudly on the constraint
instead of writing into the first org's tree. Widening it needs an importer
change, so it is written up in migration 158 §6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
A LIVE cross-user bug, not a future multi-tenant one. Both executors did
`if _mu: _set_memory_user_id(_mu); os.environ["ACB_AGENT_USER_EMAIL"] = _mu`
— set CONDITIONALLY, never unset. The conditional is what made the
never-unset lethal: a run whose payload named nobody kept whatever the last
run left, and four agents/skills fed that straight into X-User-Email.

Measured against the real run_agent before anything was touched:

  run 1 as alice, run 2 names nobody   → run 2 acted as alice
  same, on the same asyncio task       → the ContextVar leaked too
  alice and bob concurrently           → alice's agent observed bob's address
  alice concurrent with an unattributed run → the unattributed run was alice

Under D-MT-1 that email string IS the tenant, so this is also the cleanest
cross-tenant path in the system: no database predicate touches it.

TWO CORRECTIONS TO THE AUDIT, in both directions, which is why the agent was
told to verify before fixing:

  OVERSTATED — the audit blamed WS-27f's dispatch at agent_dispatch.py:144
  for passing a bare string past the guard. It never impersonated anybody: a
  string payload raises AttributeError at executor.py's list(payload.keys())
  before anything runs, and the dispatch is recorded failed. Verified by
  execution. The live paths are the callers that pass a dict and no user —
  the workflow agent node and sub-agent dispatch.

  UNDERSTATED — the audit called the ContextVar correct. It was a bare .set()
  with no reset, and an awaited coroutine runs in its CALLER's context, so
  deleting the env var alone would have left the leak intact.

The fix keeps the existing seam (acb_skills.memory_tools) and gives it a
scope. Binding is UNCONDITIONAL including the empty string, so a run that
cannot name its user has nobody to act as and its clients refuse — fail
closed rather than inherit. A second ContextVar separates "an open scope"
from "a value lying around", which is what still admits the one legitimate
inheritance: a sub-agent dispatched with no user from inside a parent that
bound one. Release is in the same `finally` as the other per-run teardown,
and a reset raising in a foreign context clears rather than leaves standing.
os.environ is gone from the identity path entirely.

17 tests driving the REAL executors and the real agent client. 13 of 15
failed on the pre-fix tree — reverted byte-identically to prove it, then
restored — with assertions like "a run that named nobody acted as
'alice@fracktal.in'". Concurrency is asserted on what the CLIENT resolves,
not on what was .set(), and gated by asyncio.Barrier so both runs are open
at once.

7 mutants, 7 killed, reverts byte-identical. Two survived the first pass and
were fixed rather than accepted: inheriting any non-empty ContextVar rather
than only an open scope, and a reader reinstating the env fallback.

Verified here, not taken on report: 1261 passed across the projects/tenancy/
agent/auth slice over two consecutive runs, ruff clean. An earlier run
showed five failures that did not reproduce — the other wave agent's
mutation harness was live-editing files mid-run, which is a real hazard of
parallel agents and the reason for the second confirming run.

CONSEQUENCE, deliberately left: workflow agent nodes now fail closed. They
build a payload with no user and set actor to "workflow:<name>", not an
email, so their gateway-calling tools refuse where they previously acted as
the last human. Threading started_by through is the correct close, but it
touches the run lifecycle and belongs with "tenant on the event".

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

get_org_id(db) did `WHERE slug = 'default'` and ignored the caller, across
27 call sites — members, roles, groups, permission overrides, access
requests, /auth/me. A tenant-B admin listed, invited into and granted roles
in tenant `default`: a cross-tenant WRITE into access control, by a
correctly-authorised caller, which is the worst shape of leak in the system
because it grants further access.

Fixed in three layers, because fixing only the first leaves the bug intact:

  1. get_org_id(db, user) resolves the CALLER's tenant, reusing WS-29b's
     resolve_organization_id rather than deriving a second answer to "which
     tenant is this". DEFAULT_ORG_SLUG is DELETED, not demoted to a
     fallback — a fallback here re-creates the bug — and the comment in its
     place records that the slug now survives only in provisioning paths
     unreachable from a request.
  2. find_member/get_member take the org and predicate on it. Every
     member-targeted route reaches its subject BY ADDRESS, so a
     caller-derived org id in front of an unscoped lookup is the same
     cross-tenant write with an extra query in front of it.
  3. the provisioning upsert grew a tenant fence, and its SET became a
     COALESCE. app_user.email is UNIQUE, so inviting another tenant's
     address conflicts with THEIR row — and the old arm moved that person
     into the inviter's organization, with set_roles (which replaces
     assignments wholesale) next in the same transaction.

WHAT THE LIVE RUN FOUND, invisible to every hermetic test and to me:

app_user_email_key is UNIQUE (email) — BYTE-EXACT — while every lookup in
this codebase matches lower(email) (R10). So a row stored as
Casey@Alpha.Example does not conflict with the lower-cased address the
inviter inserts: the fence never fires because there is no conflict, and
Postgres writes a SECOND app_user row for the same human in the other
organization. I reproduced it directly — two rows, two organizations, one
person. Under D-MT-1(a) that makes resolve_organization_id return whichever
row the planner hands back, so a person's tenant becomes non-deterministic.

⚠️ This weakens a claim I wrote into multi_tenancy.md §1.1 and built D-MT-1
on: that one-person-one-organization holds STRUCTURALLY because email is
unique. It holds only for identically-spelled addresses. The decision stands
— it is still the reversible direction — but its enforcement was
application-level, not structural, and the spec said otherwise. Corrected
there, and the migration that makes it structural follows.

Closed in application code meanwhile (the one deliberately cross-tenant
read, answering 404 like every other miss so it is not an existence oracle),
and the fakes now model the index byte-exactly so it is reproducible
hermetically.

15 mutants, no survivors, reverts byte-identical. M9 survived the first
round — the fake reads the clause out of the statement and cannot evaluate
SQL, so `OR TRUE` left the substring it looks for exactly where it was;
killed with a structural assertion that the roster carries no disjunction.
M6 and M7 are killed only structurally, which is defence in depth working:
with the other guards intact they are not behaviourally exploitable.

The frontend needed no change, and the agent checked rather than assumed:
lib/access.ts already types organization as optional and nothing derives an
org client-side. The single-org assumption was entirely server-side.

Verified here: 1508 passed across the projects/tenancy/auth/people/admin/
agent slice, ruff clean on the changed files.

LEFT AS A DECISION, not debt: the sign-in queue is genuinely shared.
access_request has no tenant column and cannot straightforwardly have one —
an address knocking at the door has no organization yet. Admin B can see and
DENY admin A's pending knock: a cross-tenant DoS on onboarding. Approve is
now fenced; deny cannot be without a routing rule (domain? invite token?).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
multi_tenancy.md §1.1 rested the whole tenant model on app_user.email being
unique, and said so with the word "structurally". It was UNIQUE (email) —
byte-exact — while every lookup in this codebase matches lower(email) (R10).
The two disagreed.

WS-29's S1-1 live run found the gap; I reproduced it directly against
Postgres before writing anything:

    INSERT app_user ('Yan.Probe@Alpha.Example', org P)   -- ok
    INSERT app_user ('yan.probe@alpha.example', org Q)   -- ALSO ok
    -- one human, two rows, two organizations

The consequence is worse than a duplicate row. resolve_organization_id —
which WS-29b made the answer to "which tenant is this caller", and which
S1-1 then made the answer for the entire admin plane — matches on
lower(email) and returns whichever row the planner hands back. A person's
tenant becomes NON-DETERMINISTIC, and so does everything scoped by it.

S1-1 closed it in application code for provision_member, the one write path
that could reach it. That guard is right and it stays, but it guards one
path: any future insert bypasses it, and "remember to lower-case here" is
the class of discipline that produced 137 unscoped tables. Migration 159 is
the version that cannot be forgotten — the functional index is created
BEFORE the byte-exact constraint is dropped, so there is never a window with
neither.

Verified: applied twice against live Postgres (idempotent — the second run
just notices the constraint is already gone), and the exact probe above is
now refused with `duplicate key value violates app_user_email_lower_key`.
Checked the live data for pre-existing case-duplicates first; none.

Deliberately does NOT normalise stored addresses. created_by, assignee,
subject and updated_by are bare address strings across a dozen tables
(D-PM-4) and none are foreign keys, so a normalising UPDATE would silently
orphan them. Stored casing is presentation; matching is already
case-insensitive by R10. The migration says so, and a test asserts no UPDATE
is present.

Five static assertions in the house style. One of them caught me the same
way lib/timeline.ts did earlier: my CONCURRENTLY check tripped on the
migration's own comment EXPLAINING why it is not concurrent. A structural
test that trips on the prose justifying the rule it enforces is a test
somebody deletes — it now strips comments before matching.

§1.1 corrected. The decision stands; the claim about its enforcement did
not, and the spec now says which is which.

Verified: 315 passed across org_access/tenancy/admin, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
Written for a coding agent with database access, which is exactly the gap
this branch was built against: a scratch Postgres, no production, no deploy,
no ability to apply a migration to the real box.

§1 branch state. 16 commits ahead, tree clean, PR #399. 2151 backend and
1106 frontend tests green, tsc/ruff/xenon/theme clean. And the first thing
the next agent must do: TWO MIGRATIONS EXIST ON NO REAL DATABASE — 158
(organization_id on all 17 pm_* plus the parent trigger) and 159
(UNIQUE (lower(email))). Both idempotent, both applied twice here. Points at
schema_migrations as the ledger rather than letting anyone assume, and warns
that schema.generated.sql is stale enough to mislead.

§2 the house rules, with the reason each exists. The verification protocol —
hermetic tests, never bare pytest on the directory, mutation testing with
byte-identical revert, and the live Postgres run that found a bug in EVERY
ticket on this branch including several where the whole hermetic suite was
green. Plus the mirror discipline: fingerprints must be specific not merely
present, and read the SQL's own column choices rather than assuming them —
both learned by watching a mutant survive.

§3 the ticket queue in dependency order, with the two landmines flagged in
place: crm_* cannot use `organization_id` because the name is taken by a
homonym pointing at a customer company, and gtd_* is scheduled for
retirement so keying it is wasted work.

§4 what an agent must refuse, and the one gate this branch LIFTED — the
ClickUp import was blocked on the pm_* tenant key, which migration 158
delivered. Replaced with the condition that actually remains: apply 158 to
the target database first.

§5 the traps, which is the part worth reading twice. asyncpg's CAST type
inference and AmbiguousParameterError; array_length('{}',1) being NULL when
a CHECK only fails on FALSE; a CHECK being unable to read another table;
byte-exact UNIQUE against lower() matching; LIKE metacharacters; midnight
UTC and DST rounding — and that both date traps are only behaviourally
testable in some timezones, so they need structural pins too; git reset
renaming so a self-rewriting script runs its old steps and exits 0; a
structural test tripping on the prose that justifies its own rule; and
parallel mutation harnesses making verification unreliable.

Corrections in these documents are marked ⚠️ and kept rather than erased,
including two of mine that were wrong in writing. A document that quietly
edits its mistakes teaches nobody where the traps are — which is the whole
point of §5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
@vjvarada vjvarada changed the title feat(WS-27o…t): recurrence · dependencies · cards · calendar · timeline · search — parity backlog closed WS-27 parity backlog closed · WS-29 multi-tenancy started — see ai-company-brain/HANDOVER.md Aug 9, 2026
claude added 2 commits August 9, 2026 06:23
⚠️ A gap in my own handover, found by checking it rather than asserting it.
HANDOVER.md §2.1 told the next agent that the live-Postgres run is
non-negotiable and to copy the patterns from the scratchpad — and all twelve
scripts lived in /tmp, in a container that gets reclaimed. They would have
been gone before anybody read the sentence pointing at them.

These found a bug in every single ticket they were written for, several
times with the entire hermetic suite green. That is the whole argument for
keeping them, and it is why they belong in the repository.

They are not unit tests: they need a real Postgres 16 with the full
migration set, they drive the REAL endpoint functions, and they exit
non-zero on any failed assertion. Named live_*.py so pytest does not collect
them — verified both ways (tests/live/ collects nothing; the unit suite is
unaffected).

The README tabulates what each one pins, which doubles as a list of the
things a fake structurally cannot catch: no type system, so
AmbiguousParameterError is invisible; no planner, so an ambiguous ORDER BY
is invisible; no constraints, so a CHECK that never fires looks like one
that works; no lower(), so a byte-exact UNIQUE agrees with code that assumed
case-folding. Every one of those was a real defect on this branch.

⚠️ Documented prominently: most of them TRUNCATE pm_projects CASCADE in
seed(). Safe against a scratch database, catastrophic against anything you
care about — and the next agent has database access, which is precisely why
that warning has to be louder than it needed to be here.

Also includes prove_bootstrap.sh, which demonstrates rather than asserts
that `git reset --hard` renames, so a self-rewriting deploy script keeps its
old inode, runs stale steps against a new tree, and exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
A document whose whole value is being accurate about the branch should not
be wrong about the size of the branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
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