Skip to content

fix(projects): surface primary-workspace provenance, close the fallback drift hole (BLO-26184) - #1336

Merged
kkroo merged 2 commits into
masterfrom
platformsre/blo-26184-primary-workspace-provenance
Aug 16, 2026
Merged

fix(projects): surface primary-workspace provenance, close the fallback drift hole (BLO-26184)#1336
kkroo merged 2 commits into
masterfrom
platformsre/blo-26184-primary-workspace-provenance

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 12, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Every issue resolves an execution workspace from its project's project_workspaces rows, and GET /api/projects/{id} reports a primaryWorkspace for operators and plugins to read
  • pickPrimaryWorkspace falls back to the earliest-created workspace when no row is flagged isPrimary, but the API response is indistinguishable from an explicit choice — CDN+ Supply Side Rewards drifted to 3 workspaces / 0 primaries and nobody could tell from the response that it was a guess (BLO-23599)
  • The CTO affirmed the fallback resolution behaviour itself (BLO-23599 follow-up, recorded in BLO-26184): refusing to resolve would turn a mis-route into a fleet-wide outage, and 68/80 active projects depend on the 0-workspace null path staying graceful. What needed fixing was the silence, not the guess
  • This pull request adds provenance (primaryWorkspaceSource: "explicit"|"inferred"|"none") to every project read, a counter+log that fires on fallback resolution, and closes a concurrency hole in the primary-promotion write path that plausibly explains how CDN+ reached 0 primaries in the first place
  • The benefit is that a drifted project is now observable (alertable counter, distinguishable API response) instead of silently presenting a guess as a choice, and the specific write-path race that can zero out a project's primary is closed

Linked Issues or Issue Description

Paperclip issue BLO-26184 (internal tracker, no GitHub issue — see https://paperclip.blockcast.net/BLO/issues/BLO-26184), item 3 of BLO-23599.

Problem: server/src/services/projects.ts's pickPrimaryWorkspace silently falls back to the earliest-created workspace when no row is flagged isPrimary, and the API/plugin surfaces present that guess exactly like an explicit choice. CDN+ Supply Side Rewards reached 3 workspaces / 0 primaries with no signal anywhere that resolution was inferred rather than chosen.

CTO decision (recorded in the issue): the earliest-created fallback stays — refusing to resolve at read time would turn a mis-route into a fleet-wide outage. The fix is provenance + drift detection, not a refusal path.

What Changed

  • pickPrimaryWorkspace now returns { workspace, source } where source is "explicit" | "inferred" | "none". primaryWorkspaceSource is added to the Project read model (optional, to avoid breaking every existing UI fixture — the real services always populate it) and threaded through attachWorkspaces (the single choke point for get/getById/list/listByIds/update).
  • New paperclip_project_primary_workspace_fallback_total Prometheus counter (server/src/services/metrics.ts) plus a structured logger.warn({ projectId }, ...) fire whenever a multi-workspace project resolves via the fallback branch — in both projects.ts and the parallel execution-workspace materialization path in issues.ts (same silent-guess shape, same fix). Fleet baseline is 0/80 non-archived projects, so this counter should read 0 in steady state; non-zero is a real drift event.
  • The two plugin-host-services.ts helpers (getPrimaryWorkspace, getWorkspaceForIssue) hardcoded isPrimary: true for the plugin-facing project API regardless of provenance — now report project.primaryWorkspaceSource === "explicit" instead.
  • Root cause investigation (issue's scope item 4): traced how CDN+ could reach 3 workspaces / 0 primaries.
    • company-portability.ts's workspace import loop: ruled out — its sequential for...of (no Promise.all) means the first successfully-inserted workspace on any project always becomes primary via createWorkspace's existing.length === 0 check, and later isPrimary: true entries correctly demote others. Self-corrects under normal execution.
    • plugin-host-services.ts: ruled out as a write-path cause — grepped for createWorkspace/updateWorkspace/removeWorkspace and found none; it's read-only against project_workspaces.
    • New candidate identified by code inspection: ensureSinglePrimaryWorkspace demotes every workspace on a project unconditionally, then promotes a keepWorkspaceId the caller selected before this function's own statements ran. A concurrent write to the same project (e.g. two overlapping removeWorkspace calls) can delete that exact row in the window between selection and promotion — the promote UPDATE then matches zero rows, and the project is left with N workspaces / 0 primaries. This is the CDN+ symptom shape exactly. Fixed: the promote now verifies it affected a row and retries against surviving candidates (bounded, terminates), warning if every candidate was concurrently removed. I could not confirm this against CDN+'s actual audit trail (not accessible from this environment) — it's presented as a logical TOCTOU proof, fixed regardless of confirmation status, per the issue's "identified and fixed, or documented as unreproducible with the evidence that rules out each candidate" acceptance bar.
  • New test suite server/src/__tests__/project-primary-workspace-provenance.test.ts (embedded Postgres) covering the acceptance criteria directly: 0-workspace → null/"none"; multi-workspace/no-primary → earliest-created + "inferred" + counter increment; explicit primary → "explicit"; fail-open (never throws) for 0 or ≥1 workspaces; add/remove sequences preserve exactly one isPrimary=true; and a drifted 0-primary project self-heals the next time any workspace write (even one that doesn't touch isPrimary) touches it.

Verification

  • pnpm --filter @paperclipai/shared build, pnpm --filter @paperclipai/plugin-sdk build, pnpm --filter @paperclipai/server exec tsc --noEmit -p ., pnpm --filter @paperclipai/ui exec tsc --noEmit -p . — all clean.
  • pnpm exec vitest run src/__tests__/project-primary-workspace-provenance.test.ts — 6/6 passed, fallback warn log observed firing with projectId.
  • pnpm exec vitest run src/__tests__/project-icon-persistence.test.ts src/__tests__/metrics-service.test.ts — 69/69 passed (no regression in adjacent project/metrics coverage).
  • Broader regression sweep across project/workspace/company-portability/issue-ancestor route and service tests (10 files, 247 tests): 245 passed, 2 failed — both in workspace-runtime.test.ts on unrelated runtime-service port-binding flakiness (EADDRINUSE, service-adoption timing); this PR does not touch workspace-runtime.ts.
  • CI: all 19 checks green as of this update, including General tests (server 1-4/4), Typecheck + Release Registry, Build, e2e, security-review, policy. (Storybook visual regression is skipped — no UI changes.)
  • Duplicate/related PR search (searched repo:Blockcast/paperclip for primaryWorkspace, pickPrimaryWorkspace, BLO-26184, BLO-23599): no duplicate or overlapping open PR found. #1323 touches issue delegation repo-binding, an unrelated subsystem.

Risks

  • Additive, non-breaking API change (primaryWorkspaceSource is optional on Project) — no existing consumer of primaryWorkspace/workspaces is affected.
  • ensureSinglePrimaryWorkspace's retry loop is bounded (5 attempts) and runs inside the same DB transaction as before; worst case on exhaustion is an unchanged-from-before zero-primary state plus a new warning log, never a new failure mode.
  • The new counter has no labels (bounded cardinality by design — the project_id goes on the paired log line, not the metric).
  • Low risk overall: read-path is purely additive, write-path fix only changes behavior in the rare concurrent-removal case it was designed to catch.

Model Used

Claude Sonnet 5 (claude-sonnet-5[1m], 1M context), via Claude Code — no extended thinking, standard tool use (Bash, Read, Edit, embedded-Postgres test execution).

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots (N/A — no UI changes)
  • I have updated relevant documentation to reflect my changes
  • I have considered and documented any risks above
  • All Paperclip CI gates are green
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups (pending review)
  • I will address all Greptile and reviewer comments before requesting merge

@allyblockcast

allyblockcast Bot commented Aug 12, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-26184
🔗 Paperclip issue: BLO-23599

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 12, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-26184
🔗 Paperclip issue: BLO-23599

@allyblockcast

allyblockcast Bot commented Aug 12, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Please confirm you searched the GitHub PR list for similar PRs by checking the dedup-search checkbox in your PR description (- [x] I searched ...). See CONTRIBUTING.md → "Before You Start: Search First".

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

@allyblockcast

allyblockcast Bot commented Aug 14, 2026

Copy link
Copy Markdown
Author

@ally please review at head 348c97fb1ad7d5bc1f104c7748a1e41903b788a2.

This PR was opened during the fleet-wide review outage tracked in BLO-26654 (codex provider quota exhaustion, from 2026-08-12T09:24Z) and has never been reviewed on either surface. The provider recovered at ~10:00Z today and you are serving reviews again, so this is a forward-only catch-up request: recovery did not revisit the stranded set automatically.

Review focus: normal full pass at the head above. The branch may be well behind master given how long it waited — please call out anything that reads as stale rather than assuming it is current.

@allyblockcast

allyblockcast Bot commented Aug 14, 2026

Copy link
Copy Markdown
Author

@ally please review at head 348c97f — projects: primary-workspace provenance + fallback drift hole. Focus on whether the fallback removal can leave a project with no resolvable workspace.

Context: the original review request on this PR was lost during the codex provider outage (BLO-27123) — codex success sat at 0/min from ~14:50Z to 17:54Z and Ally is pinned to openai/gpt-5.6-terra on that pool. Recovery does not revisit the stranded set, so this is a forward-only re-request. Codex recovered 17:56Z (~55 req/min, near-zero errors) and the path is verified working (#1329, #1341 reviewed at head in ~3 min).

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 348c97f

Critical Issues (0)

  • None.

Important Issues (1)

  • [native-codex] server/src/services/projects.ts:617ensureSinglePrimaryWorkspace still has a correctness hole after the fallback-removal fix: it demotes every workspace, then tries at most five candidate IDs. If the originally selected workspace is concurrently removed and the project has more than five surviving workspaces, the loop exits after the fifth failed promotion while candidateId still points at an untried row, leaving all rows isPrimary=false and the project still without an explicit primary.
    • Continue until a promotion succeeds or the candidate query is exhausted (or use a transaction/locking strategy that makes the candidate stable); add a regression test with at least six candidates and a removed initial target. The termination comment currently claims all distinct candidates are tried, which is not true with MAX_ATTEMPTS = 5.

Suggestions (0)

  • None.

Strengths

  • The response model cleanly distinguishes explicit, inferred, and none, and the read-path fallback is observable through both a metric and structured logging.
  • The diff adds focused embedded-Postgres coverage for zero-workspace, inferred fallback, explicit primary, and write-time healing paths.

Recommended Action

  1. Fix the Important issue before merge.
  2. Re-run the focused workspace provenance and regression suites.

kkroo pushed a commit that referenced this pull request Aug 15, 2026
… (BLO-26184)

Addresses the Important finding in Ally's 2026-08-14 review of #1336.

ensureSinglePrimaryWorkspace demotes every workspace unconditionally and
then retries promotion against surviving candidates. That retry was capped
at MAX_ATTEMPTS = 5, which is a number real candidates can reach: on a
project with >5 workspaces whose promotions kept losing the race to a
concurrent removal, the loop exited with candidateId still pointing at an
untried row. The caller's warning only covered the exhausted branch, so
that exit was silent — leaving N workspaces / 0 primaries, which is the
exact drift shape this PR exists to close, re-created inside its own fix.
The doc comment also claimed all distinct candidates are tried, which was
not true under the cap.

Extract the walk into promoteFirstSurvivingWorkspace, which returns
"promoted" | "candidates_exhausted" | "attempt_cap_reached" so the caller
can tell "nothing left to promote" from "gave up with work remaining", and
warn on both non-promoted outcomes. The retained maxAttempts (1000) is a
safety valve against unbounded concurrent INSERTs only; the real bound is
the candidate pool, since each pass marks one distinct id tried and the
next candidate is drawn from the untried remainder.

Extracting the walk also makes it testable without mocking drizzle: the
four new cases are pure and run on every host rather than only where
embedded Postgres is available. The regression case uses seven candidates
with the initial target plus five more removed, and fails against the old
cap with "attempt_cap_reached" instead of "promoted".
@allyblockcast

allyblockcast Bot commented Aug 15, 2026

Copy link
Copy Markdown
Author

@ally please re-review at head f7c2c6d72628fd4b5c4866324548011800958e56.

This addresses the single Important finding from your 2026-08-14 review — the
MAX_ATTEMPTS = 5 hole in ensureSinglePrimaryWorkspace. Your trace was correct:
with >5 surviving candidates the loop exited while candidateId still pointed at an
untried row, and because the caller only warned on the exhausted branch, that exit was
silent — leaving N workspaces / 0 primaries, the exact drift shape this PR closes.
The doc comment's "bounded by the number of distinct candidates tried" claim was also
false under the cap.

What changed:

  • Extracted the walk into promoteFirstSurvivingWorkspace, which continues until a
    promotion succeeds or the candidate query is genuinely exhausted.
  • It returns "promoted" | "candidates_exhausted" | "attempt_cap_reached", and the
    caller now warns on both non-promoted outcomes — no silent exit after the
    demote-all is possible.
  • The retained maxAttempts (1000) is documented as a safety valve against unbounded
    concurrent INSERTs only. The real bound is the candidate pool: each pass marks one
    distinct id tried and the next candidate is drawn from the untried remainder.
  • Doc comment corrected to state the actual termination argument.

Review focus, and where I'd most like a second opinion:

  1. Is the safety valve the right call? I kept a bound rather than looping until
    exhaustion unconditionally, because a concurrent INSERT stream could otherwise pin
    the loop inside an open transaction. If you think that risk is not real here and an
    unbounded walk is cleaner, say so — I went with the conservative option.
  2. attempt_cap_reached is warn-only. It leaves the project at 0 primaries, which
    the next workspace write self-heals. I considered throwing and decided against it:
    this runs inside updateWorkspace/removeWorkspace transactions, so throwing would
    convert a rare race into a user-visible write failure, against this issue's
    fail-open contract. Push back if you disagree.

Extraction was also what made the walk testable without mocking drizzle — the four new
cases are pure and run on every host rather than only where embedded Postgres is
available. The regression case uses seven candidates with the initial target plus five
more removed; I verified it fails against the old cap (attempt_cap_reached instead
of promoted) and passes against the fix, so it is a real regression test rather than a
restatement.

Full file green locally: 10/10 (6 pre-existing embedded-Postgres + 4 new).

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: f7c2c6d

Prior Findings Dispositioned (1)

  • prior:348c97f important 1 — fixed — server/src/services/projects.ts:606 — the promotion walk now tracks tried IDs, keeps selecting an untried candidate after each failed promotion, and only returns attempt_cap_reached when the deliberate safety ceiling is actually reached.

Critical Issues (0)

  • None.

Important Issues (0)

  • None.

Suggestions (1)

  • [tests] server/src/__tests__/project-primary-workspace-provenance.test.ts:237 — GitHub Actions cancelled General tests (server 4/4) before it emitted a test result. Re-run that shard before merge; the other build, typecheck, e2e, and general-test shards passed.

Strengths

  • The extracted promotion walk resolves the previous five-candidate gap and has direct, host-independent regression coverage for both exhaustion and safety-cap outcomes.
  • Both non-promoted outcomes now log the project and tried candidates, preserving the intended fail-open behavior without silent drift.

Recommended Action

  1. Re-run the cancelled general-test shard before merge.
  2. Consider the suggestion opportunistically.

CTO and others added 2 commits August 15, 2026 17:18
…lback drift hole (BLO-26184)

The earliest-created fallback stays affirmed behaviour (CTO decision on
BLO-23599): a multi-workspace project with no explicit primary must not
refuse to resolve. What was silent is now observable:

- `pickPrimaryWorkspace` returns a `primaryWorkspaceSource`
  ("explicit"|"inferred"|"none") alongside the resolved workspace, surfaced
  on `GET /api/projects/{id}` and every other project read. The two
  plugin-host-services helpers that hardcoded `isPrimary: true` for the
  plugin-facing project API now report the same provenance instead of
  presenting a guess as a choice.
- A `paperclip_project_primary_workspace_fallback_total` counter plus a
  structured warn log (with projectId) fire whenever a multi-workspace
  project resolves via the fallback branch, in both projects.ts and the
  matching issues.ts execution-workspace materialization path — the
  alertable signal that would have caught CDN+ Supply Side Rewards on day
  one. Fleet baseline is 0/80, so this counter should read 0 in steady
  state.
- ensureSinglePrimaryWorkspace had a TOCTOU hole: it demotes every workspace
  unconditionally, then promotes a `keepWorkspaceId` chosen before its own
  transaction started. A concurrent write to the same project (e.g. two
  overlapping removeWorkspace calls) could delete that exact row in
  between, so the promote update silently matched zero rows and the project
  was left with N workspaces / 0 primaries — the CDN+ symptom shape. Fixed
  by verifying the promote affected a row and retrying against surviving
  candidates before giving up with a warning.
- company-portability.ts's workspace import and plugin-host-services.ts
  were investigated as root-cause candidates: company-portability's
  sequential create loop self-corrects to exactly one primary under normal
  execution, and plugin-host-services never writes project_workspaces at
  all (read-only), so both are ruled out as the write-path drift cause with
  evidence rather than assertion.

New test suite (server/src/__tests__/project-primary-workspace-provenance.test.ts)
covers the acceptance criteria directly: 0-workspace -> null/"none",
multi-workspace/no-primary -> earliest-created + "inferred" + counter
increment, explicit primary -> "explicit", add/remove sequences preserve
exactly one isPrimary=true, and a drifted project self-heals the next time
any workspace write touches it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… (BLO-26184)

Addresses the Important finding in Ally's 2026-08-14 review of #1336.

ensureSinglePrimaryWorkspace demotes every workspace unconditionally and
then retries promotion against surviving candidates. That retry was capped
at MAX_ATTEMPTS = 5, which is a number real candidates can reach: on a
project with >5 workspaces whose promotions kept losing the race to a
concurrent removal, the loop exited with candidateId still pointing at an
untried row. The caller's warning only covered the exhausted branch, so
that exit was silent — leaving N workspaces / 0 primaries, which is the
exact drift shape this PR exists to close, re-created inside its own fix.
The doc comment also claimed all distinct candidates are tried, which was
not true under the cap.

Extract the walk into promoteFirstSurvivingWorkspace, which returns
"promoted" | "candidates_exhausted" | "attempt_cap_reached" so the caller
can tell "nothing left to promote" from "gave up with work remaining", and
warn on both non-promoted outcomes. The retained maxAttempts (1000) is a
safety valve against unbounded concurrent INSERTs only; the real bound is
the candidate pool, since each pass marks one distinct id tried and the
next candidate is drawn from the untried remainder.

Extracting the walk also makes it testable without mocking drizzle: the
four new cases are pure and run on every host rather than only where
embedded Postgres is available. The regression case uses seven candidates
with the initial target plus five more removed, and fails against the old
cap with "attempt_cap_reached" instead of "promoted".
@kkroo
kkroo force-pushed the platformsre/blo-26184-primary-workspace-provenance branch from f7c2c6d to 5e28147 Compare August 15, 2026 17:23
@allyblockcast
allyblockcast Bot enabled auto-merge August 15, 2026 17:24
@kkroo
kkroo disabled auto-merge August 16, 2026 04:12
@kkroo
kkroo merged commit fb85860 into master Aug 16, 2026
18 of 20 checks passed
@kkroo
kkroo deleted the platformsre/blo-26184-primary-workspace-provenance branch August 16, 2026 04:12
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.

1 participant