Skip to content

Expose project folder membership and root filtering (#88) - #205

Merged
deverman merged 1 commit into
masterfrom
issue-88-project-folder-membership
Aug 2, 2026
Merged

Expose project folder membership and root filtering (#88)#205
deverman merged 1 commit into
masterfrom
issue-88-project-folder-membership

Conversation

@deverman

@deverman deverman commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Closes #88. Unblocks #171, which needs project paths so batch-resolved duplicate names stay distinguishable.

Problem

A model asked which projects sit outside folders and could not answer it. folderID and folderName were dropped because ProjectItem carried no membership at all, so the model could only sum folder projectCount values to infer that ~91 of 379 projects were unfiled — never identify which.

Change

list_projects returns folderID, folderName, and folderPath, read from the documented project.parentFolder relationship rather than reconstructed by scanning folders. folderPath lists ancestors root-first so repeated folder names stay distinguishable, and is depth-capped so a malformed parent cycle cannot spin. A project at the library root reports null for all three.

A rootOnly filter scopes the query server-side to projects whose parentFolder is null.

rootOnly is part of the projects cache key. Without that, a cached full-catalogue page would satisfy a root-only request and vice versa — returning filed projects to a query asking for unfiled ones, which is exactly the confusion this feature exists to remove. It also enters the query identity, so a cursor issued for one scope cannot resume the other. Both are pinned by tests.

Measured against the UAT baseline

On a 392-project database:

items payload latency
full catalogue (UAT approach) 392 27024 B 413 ms
rootOnly=true 102 8765 B 416 ms

Model-visible items 74% fewer, payload 68% smaller. Per-call latency is unchanged — the bridge still walks flattenedProjects, so the filter simply runs there instead of in the model. The win is that the question becomes answerable at all, and answerable compactly; it is not a latency optimisation and is not presented as one.

Live verification

check result
rootOnly count vs folderID == null in full catalogue 102 = 102, exact
filed projects missing folderName 0 of 290
folderPath tail ≠ folderID 0
root projects leaking folder data 0
projects with nested path (depth > 1) 62
composes with status filter active 73/275, all 102/392, done 10/16
composes with pagination 40-item page issues a cursor

Direct MCP probes confirmed the same through the wire, including that unsupported field names are rejected (list_projects.fields contains unsupported field(s): bogusField) — that acceptance criterion turned out to be already satisfied before this work.

Validation

Impact: transport-reliability per focusrelay-dev classify (it flags BridgeClient, whose only change here is threading a rootOnly parameter).

  • 281 tests pass, including 9 new ones covering root/filed/nested/duplicate-name membership, cache keying, and cursor identity.

  • All semantic gates pass; validate --impact query clean.

  • The 10-minute smoke run failed closed, and I am not claiming otherwise: 1 timeout in 66 measured calls (1.52%), on get_project_counts / project_view_everything, pickupState=bridge_processing at the 45 s deadline.

    Attribution: this change cannot reach that path. Every JS edit lies between lines 2526-2660, inside the list_projects branch; get_project_counts begins at line 3177 and is untouched, and the new code is gated behind hasField(...) and rootOnly === true. The run's own baseline was already slow — p50 9.4 s, p95 15.1 s, p99 17.4 s — on a machine that had been running benchmarks and heavy OmniFocus work for hours.

    A re-run is in progress and this PR will be updated with the result. Do not merge on the strength of the failed run alone.

🤖 Generated with Claude Code

A model asked which projects sit outside folders and could not answer it.
folderID and folderName were silently dropped because ProjectItem carried
no membership at all, so the model could only sum folder projectCount
values to infer that some projects were unfiled -- never identify them.

list_projects now returns folderID, folderName, and folderPath, read from
the documented project.parentFolder relationship rather than reconstructed
by scanning folders. folderPath lists ancestors root-first so repeated
folder names stay distinguishable, and is depth-capped so a malformed
parent cycle cannot spin. A project at the library root reports null for
all three.

A rootOnly filter scopes the query server-side to projects whose
parentFolder is null, so reviewing unfiled projects no longer means
fetching the whole catalogue.

rootOnly is part of the projects cache key. Without that, a cached
full-catalogue page would satisfy a root-only request and vice versa,
which is precisely the bug this feature exists to avoid. It also enters
the query identity, so a cursor issued for one scope cannot resume the
other.

Measured against the 379-project UAT baseline, on a 392-project database:
model-visible items 392 to 102 (74% fewer), payload 27024 to 8765 bytes
(68% smaller), per-call latency unchanged at ~415ms. The latency is flat
because the bridge still walks flattenedProjects; the win is that the
question is answerable at all, and answerable compactly.

Live verification: rootOnly returned exactly the 102 projects whose
folderID is null in the full catalogue, every filed project carried a
folderName, every folderPath ended at its folderID, no root project
leaked folder data, and 62 projects reported nested paths.

Validation impact: transport-reliability per focusrelay-dev classify.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@deverman

deverman commented Aug 1, 2026

Copy link
Copy Markdown
Owner Author

Smoke re-run: also failed closed, but the failure moved

Second 10-minute smoke run on the same build: also failed closed, on a different scenario.

run failing scenario measured calls failure rate scenario p95 the failing call
1 get_project_counts / project_view_everything 66 1.52% 15066 ms 45055 ms (timeout)
2 get_task_counts / search_no_match 210 0.48% 2775 ms 45037 ms (timeout)

Run 2 detail: get_task_counts 210 measured / 1 failure, get_project_counts 53 measured / 0 failures, list_tasks 75 measured / 0 failures.

Why this reads as environmental rather than a defect in this change

  1. The failure moved between runs. get_project_counts failed in run 1 and passed 53/53 in run 2. A deterministic regression does not migrate between unrelated scenarios.
  2. Both failing paths are untouched here. Every JS edit is between lines 2526-2660 inside the list_projects branch; get_task_counts and get_project_counts begin at later lines and are not modified. The new code is gated behind hasField(...) and rootOnly === true, so neither op can execute any of it.
  3. The run-2 failure is a 16x outlier against its own scenario. search_no_match is the cheapest scenario in the suite at p95 2775 ms, yet one call stalled to 45 s. That is a stall, not a slow code path.
  4. The host is loaded. Load average ~5 across both runs, 16+ hours uptime, after a long session of benchmarking and heavy OmniFocus work.

Where that leaves the merge gate

The transport-reliability tier requires a passing targeted smoke, and it has not passed. I am not treating the reasoning above as a substitute for a green run.

Decisive next step, if wanted: run the same smoke profile on origin/master with the released plug-in on this host. If master fails the same way, the flakiness is pre-existing and this PR is not the cause; if master passes twice, that changes the picture and this needs real investigation. Related prior art: #193 already tracks benchmark evidence being disturbed by host conditions.

Recommendation: do not merge on the current evidence. Either establish the master baseline, or re-run smoke on a quiesced host.

@deverman

deverman commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

Smoke gate now passes — plus a master baseline for attribution

Result

Benchmark suite evidence passed: .build/benchmarks/smoke-20260802-075554

265 measured calls, 0 failures, at host load ~4.7 — the same load at which the two earlier runs failed.

benchmark measured failures p50 p95 max
list_tasks 76 0 11838 ms 14011 ms 15072 ms
get_task_counts 121 0 2789 ms 13125 ms 14209 ms
get_project_counts 68 0 11409 ms 13887 ms 16224 ms

Full picture across four runs

run build host load measured failures
1 this branch ~5 66 1 (get_project_counts/project_view_everything)
2 this branch ~5 338 1 (get_task_counts/search_no_match)
3 origin/master ~3 315 0
4 this branch ~4.7 265 0

Run 3 used master's own plug-in and release binary in a separate worktree, so it is a like-for-like baseline rather than a mixed-version comparison.

Reading

The branch now has a clean smoke run at the load where it previously failed, so the gate is satisfied on its own terms.

On attribution, the honest statement is that the earlier failures were never distinguishable from host flakiness: 2 failures in ~404 branch calls against 0 in 315 on master is about p≈0.5 by Fisher's exact test — indistinguishable from chance. Run 4 is consistent with that, and adds a positive result at matched load, but four runs cannot prove a ~0.4% event absent. What can be said concretely is that no failure has ever occurred in a code path this change touches, and that both failures were single 45 s stalls against scenario p95s of 2.8 s and 15.1 s respectively.

Worth filing separately

This host runs close to the deadline regardless of branch: master's own passing run peaked at 29611 ms against the 45 s timeout. A suite that fails closed on one stalled call, on a host whose tail routinely reaches 60-70% of the deadline, will keep producing results like runs 1 and 2. That is an evidence-infrastructure problem rather than a product defect, and it sits close to #193, which already tracks benchmark evidence being disturbed by host conditions.

Previous comment's recommendation not to merge is withdrawn: the required gate has now passed.

@deverman

deverman commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

Direct performance measurement of the changed path

The smoke suite benchmarks list_tasks, get_task_counts, and get_project_countsit does not benchmark list_projects at all, so none of the four smoke runs measured this change's performance. Measuring the changed path directly instead, 392-project database, 10 samples each, interleaved:

query median items bytes vs baseline
master/release, id,name 390 ms 392
branch, id,name 385 ms 392 28728 −4 ms (within noise)
branch, +folderID,folderName 436 ms 392 45087 +50 ms (+13.1%)
branch, +folderPath 440 ms 392 66061 +56 ms (+14.4%)
branch, rootOnly + id,name 385 ms 102 7399 0 ms

Sample stdev 20-30 ms, so standard error ≈ 9 ms at n=10.

No regression for existing callers. 385 ms against 390 ms is indistinguishable from noise, which is what the implementation predicts: the folder work sits behind hasField(...) and does not execute unless folderID, folderName, or folderPath is requested.

Opt-in cost is ~50 ms (+13%) on a full-catalogue query when folder data is requested. That is a real effect rather than noise (≈5 standard errors), and it buys information that was previously unobtainable at any cost. Adding folderPath on top costs a further ~4 ms.

The targeted workflow is strictly better: rootOnly is identical in latency while returning 102 items and 7399 bytes instead of 392 and 28728 — same responsiveness, a quarter of the payload, which is the point of the issue.

Conclusion: merging does not reduce responsiveness on any existing path.

@deverman
deverman merged commit 844e53d into master Aug 2, 2026
4 checks passed
@deverman
deverman deleted the issue-88-project-folder-membership branch August 2, 2026 00:49
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.

Expose project folder membership and root filtering in list_projects

1 participant