Skip to content

Approvals: one decision = one pending item — semantic dedup + realpath-canonical roots (#494, #505) - #509

Merged
richard-devbot merged 3 commits into
mainfrom
claude/approval-dedup-494
Jul 31, 2026
Merged

Approvals: one decision = one pending item — semantic dedup + realpath-canonical roots (#494, #505)#509
richard-devbot merged 3 commits into
mainfrom
claude/approval-dedup-494

Conversation

@richard-devbot

Copy link
Copy Markdown
Owner

Two bisected commits, TDD throughout (all tests verified failing on unfixed code first). The second bug was discovered blocking the first one's end-to-end contract — the E2E probe is in the story below.

Closes #494
Closes #505

Commit 1 — #505: realpath-canonical roots

macOS's /tmp and /var are symlinks into /private, so the registry and --project could name one directory two ways; lexical resolve() kept both and the Hub registered one project twice — "2 projects" for one repo, doubled queue approvals, doubled feed rows/counts. Root intake (state/roots.js + the injected-roots branch of buildFullState) now canonicalizes through fs.realpath before dedup, and identity (resolveProjectDescriptor, runScopeKey, decorateRunIdentity) hashes the canonical path — identity is a function of the real filesystem node, never the spelling. Missing directories degrade to the lexical form (no throw); only successful resolutions cache, so late-appearing roots get their real identity. Pinned by a real-symlink unit test (one root from two spellings; distinct dirs stay distinct; ghosts don't throw). Fixture-pinned tests now derive expectations from the canonical base — same assertions, canonical spelling.

Commit 2 — #494: semantic approval dedup

The event-derived gap-filler (#274) deduped against queue entries by raw id, so any queue card whose id isn't the recomputed canonical approvalQueueId (pre-#274 runs, hand-authored entries, the canonical fixtureBlockedRun card) duplicated: two pending cards, "Approvals 2 pending", and a Studio rail "holding 2 gate(s): guardrail-override:07-code, guardrail-override:07-code" for one human decision.

  • A decision is now (runId, taskId, artifact) (approvalDecisionKey, exported). The queue-backed entry wins; the derived request stays strictly a gap-filler — no-card still derives, unrelated artifacts/other runs never suppress, canonical-id dedup unchanged (all pinned).
  • The semantic Studio's governance deck had the sibling problem: it lists blocked-gate records AND pending approvals — one decision counted twice. A gate fully covered by a pending approval for the same task is suppressed; uncovered gates still surface as observed truth (pinned both ways).

The E2E probe that found #505

After commit 2's unit tests went green, the browser E2E still saw 2 pending — a /api/state dump showed the same queue card twice, once per root spelling (/var/... and /private/var/...): the derived duplicate was gone, and the remaining duplicate was #505. Tuple-dedup across projects would have been wrong (equal run ids in genuinely different projects must stay distinct — pinned by the existing collision tests), so the root fix went where it belongs.

Verification

  • Core suite 1720/1720 (exit 0) · browser regression 6/6 (1 new E2E + dashboard-96's 5 incl. axe + responsive) · lint 0 errors · typecheck 0 · validate 196 agents · schemas 34 PASS · security gate green · whitespace clean.
  • Live-verified on the fixture-seeded hub after a server restart: topbar "Approvals 1 pending", Approvals page shows exactly one actionable card ("Override guardrail for 07-code"), Studio rail reads "holding 1 gate(s): guardrail-override:07-code".

Branch is off current main — independent of PRs #504 and #508 (no shared files).

Held for Richardson's batch review — please verify before merge.

🤖 Generated with Claude Code

richardsongunde and others added 2 commits July 31, 2026 10:09
…#505)

macOS's /tmp and /var are symlinks into /private, so the registry and the
--project argument could name the same directory with two spellings.
Lexical path.resolve kept both: the Hub registered one project twice —
"2 projects" for one repo, doubled queue approvals, doubled feed rows and
task counts (observed live during the #495 test work, and the reason
#494's one-decision-one-card contract could not hold end to end).

Root intake (state/roots.js sourceRoots + the injected-roots branch in
buildFullState) now canonicalizes through fs.realpath before dedup, and
project/run identity (state/identity.js resolveProjectDescriptor,
runScopeKey, decorateRunIdentity) hashes the canonical path — identity is
a function of the real filesystem node, never of the spelling that
reached us. A missing directory degrades to its resolved lexical form
instead of throwing, and only successful resolutions are cached so a root
that appears later gets its real identity then.

Test fixtures that pinned descriptor paths now derive expectations from
the canonical (realpath) fixture base — same assertions, canonical
spelling.

Closes #505.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The event-derived gap-filler (approvalRequestsFromBlockedGates, #274)
deduped against queue entries by raw id only, so a queue card whose id is
not the recomputed canonical approvalQueueId — pre-#274 runs,
hand-authored entries, the canonical fixtureBlockedRun card — produced a
duplicate pending card, an inflated "Approvals 2 pending" topbar chip,
and a Studio rail reading "holding 2 gate(s): guardrail-override:07-code,
guardrail-override:07-code" for a single human decision.

Dedup is now semantic: a decision is (runId, taskId, artifact), exported
as approvalDecisionKey. The queue-backed entry wins (it is the actionable
record); the derived request stays strictly a gap-filler — the no-card
case still derives it, unrelated artifacts and other runs never suppress
it, and canonical-id dedup keeps working (all pinned).

The semantic Studio's governance deck had the sibling problem one level
up: it lists blocked-gate records AND pending approvals, counting one
decision twice. A gate whose every missing artifact is covered by a
pending approval for the same task is now suppressed — the actionable
approval represents the decision; uncovered gates still surface as
observed truth.

Browser-pinned end to end on fixtureBlockedRun (the exact repro): one
pending approval in the snapshot, "1 pending" in the topbar, exactly one
actionable card, and a Studio rail holding one gate. The end-to-end
contract also needed #505 (the same queue card was being read through two
spellings of one root).

Closes #494.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@strix-security

strix-security Bot commented Jul 31, 2026

Copy link
Copy Markdown

Strix Security Review

1 open security finding on this PR:

Review summary

Reviewed all security-relevant changes in the PR, including the updated dashboard state assembly, approval deduplication, root canonicalization, and the added regression coverage. One confirmed issue remains in the new semantic approval/governance dedup logic: approvals and blocked-gate governance items can be suppressed across different project roots when those roots share the same runId, because the new keying omits project identity. The root-canonicalization changes themselves did not introduce a security issue in the reviewed diff.

Fixed the findings? re-run the review, or tag @strix-security in a PR comment to run a fresh review.

Updated for f814bc2.


Reviewed by Strix
Re-run review · Configure security review settings

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@richard-devbot, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 24 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f2965514-5529-48d3-9c35-b8249b923d87

📥 Commits

Reviewing files that changed from the base of the PR and between 95ca137 and d463c18.

📒 Files selected for processing (10)
  • src/observability/dashboard/state/approvals.js
  • src/observability/dashboard/state/identity.js
  • src/observability/dashboard/state/index.js
  • src/observability/dashboard/state/roots.js
  • src/observability/dashboard/state/studio.js
  • tests/approval-dedup-494.test.js
  • tests/browser/dashboard-494-approval-dedup.test.js
  • tests/dashboard-business-flex-state.test.js
  • tests/dashboard-scope-state.test.js
  • tests/source-roots-canonical-505.test.js

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Dedup approvals by semantic decision key and canonicalize roots via realpath

🐞 Bug fix 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Canonicalize project roots with realpath to prevent duplicate projects on symlinked paths.
• Dedup pending approvals by (runId, taskId, artifact) to avoid double-counting decisions.
• Add unit + browser E2E coverage to lock in one-decision-one-item behavior.
Diagram

graph TD
  A["Root inputs (CLI/registry)"] --> B["roots.js canonicalSourceRoots"] --> C["state/index.js buildFullState"] --> D["identity.js canonicalPath"] --> G[("Filesystem realpath")]
  C --> E["approvals.js decision dedup"] --> F["studio.js governance items"]
  B --> G
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Canonicalize only at CLI boundary
  • ➕ Centralizes realpath logic at a single ingress point
  • ➕ Avoids repeated canonicalization inside state/identity code
  • ➖ Does not cover registry-discovered roots or other internal callers
  • ➖ Risks reintroducing duplicates when roots enter via non-CLI paths
2. Migrate legacy approval queue IDs to canonical approvalQueueId
  • ➕ Simplifies dedup by making raw IDs match the canonical recomputation
  • ➕ Reduces need for semantic-key logic in projections
  • ➖ Requires a data migration and backward-compat handling
  • ➖ Still doesn’t address non-canonical/human-authored IDs reliably
3. Dedup approvals only within Studio (presentation-layer fix)
  • ➕ Minimal code churn in approvals pipeline
  • ➕ Targets the user-visible double-counting first
  • ➖ Leaves API/state surfaces inconsistent (topbar counts, approvals page, etc.)
  • ➖ Harder to reason about correctness across multiple consumers

Recommendation: Keep the PR’s approach: canonicalize roots at intake and in identity hashing, and dedup approvals by a semantic (runId, taskId, artifact) decision key. This fixes the root cause for cross-surface duplication (state snapshot, counts, and Studio) without relying on fragile legacy ID spellings or risky migrations, and it correctly preserves isolation across genuinely distinct projects.

Files changed (10) +454 / -20

Bug fix (5) +93 / -15
approvals.jsDedup derived approvals using semantic decision key +21/-3

Dedup derived approvals using semantic decision key

• Adds an exported approvalDecisionKey(runId, taskId, artifact). Updates approvalRequestsFromBlockedGates to dedup derived gap-filler requests against queue approvals by both raw id and semantic key, ensuring legacy/non-canonical queue cards suppress duplicates.

src/observability/dashboard/state/approvals.js

identity.jsHash identity using realpath-canonical project roots +29/-6

Hash identity using realpath-canonical project roots

• Introduces canonicalPath() with memoization backed by realpathSync, falling back to lexical resolve() when paths are missing. Uses canonicalPath in resolveProjectDescriptor, runScopeKey, and decorateRunIdentity to prevent duplicate project/run identities from symlinked root spellings.

src/observability/dashboard/state/identity.js

index.jsCanonicalize injected source roots in buildFullState +5/-2

Canonicalize injected source roots in buildFullState

• Switches the injected-roots branch to use canonicalSourceRoots() so both injected and registry-discovered roots dedup via realpath. Adds inline documentation tying this to the macOS symlink root duplication issue.

src/observability/dashboard/state/index.js

roots.jsRealpath-canonical root intake with safe fallback +24/-2

Realpath-canonical root intake with safe fallback

• Adds canonicalRoot() (realpath with lexical fallback) and exports canonicalSourceRoots() for deduping root lists after canonicalization. Updates sourceRoots() to canonicalize the projectRoot and registry roots, preventing one directory from being treated as multiple projects.

src/observability/dashboard/state/roots.js

studio.jsSuppress blocked-gate items covered by pending approvals +14/-2

Suppress blocked-gate items covered by pending approvals

• Computes pending approvals for a run and builds a coverage set keyed by taskId+artifact. Filters blocked-gate governance items so gates whose missing artifacts are fully covered by pending approvals are not listed alongside the approval, preventing one decision from appearing twice.

src/observability/dashboard/state/studio.js

Tests (5) +361 / -5
approval-dedup-494.test.jsUnit tests for semantic approval dedup and Studio suppression +154/-0

Unit tests for semantic approval dedup and Studio suppression

• Adds TDD coverage ensuring non-canonical queue card IDs still suppress derived approvals, canonical-id dedup remains intact, and unrelated artifacts/runs do not over-dedup. Also validates Studio governance items show exactly one item per decision (approval wins) while uncovered gates still surface.

tests/approval-dedup-494.test.js

dashboard-494-approval-dedup.test.jsPlaywright E2E: DOM shows one pending approval for one decision +149/-0

Playwright E2E: DOM shows one pending approval for one decision

• Adds a real-browser test that boots the dashboard server on an ephemeral port, seeds fixtureBlockedRun, and asserts a single pending approval in window.STATE, topbar chip, Approvals page cards, and Studio rail text. Cleanly skips when Chromium is unavailable.

tests/browser/dashboard-494-approval-dedup.test.js

dashboard-business-flex-state.test.jsAdjust fixture roots to canonical realpath in expectations +4/-2

Adjust fixture roots to canonical realpath in expectations

• Updates policyRoot() to realpath the mkdtemp directory so expected roots match canonicalized projections on platforms where tmpdir is symlinked (notably macOS).

tests/dashboard-business-flex-state.test.js

dashboard-scope-state.test.jsMake scope/identity fixtures use realpath-canonical base +5/-3

Make scope/identity fixtures use realpath-canonical base

• Updates mkdtemp-based fixtures to realpathSync so project descriptors and scope tests align with canonical root behavior. Prevents test brittleness under tmpdir symlink scenarios.

tests/dashboard-scope-state.test.js

source-roots-canonical-505.test.jsUnit tests for root canonicalization and safe fallback +49/-0

Unit tests for root canonicalization and safe fallback

• Adds tests verifying that a symlink and its real directory dedup to a single canonical root, missing directories fall back to lexical paths without throwing, and distinct real directories remain distinct (no over-merging).

tests/source-roots-canonical-505.test.js

@qodo-code-review

qodo-code-review Bot commented Jul 31, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (3)

Context used
✅ Compliance rules (platform): 300 rules
✅ Skills: 17 invoked
  code-review-pr
  claude-api
  documentation-writing
  pptx
  docx
  performance-monitoring
  security-compliance
  cso
  plan-eng-review
  design-review
  prompt-engineering
  mcp-builder
  qa-testing
  code-patterns
  xlsx
  security-owasp
  testing-qa

Grey Divider


Remediation recommended

1. Realpath errors swallowed ✓ Resolved 🐞 Bug ☼ Reliability
Description
canonicalPath()/canonicalRoot() catch all realpath failures and silently fall back to lexical
resolve(), so permission/I/O/symlink-loop errors (not just ENOENT) become invisible and can
reintroduce duplicate root/project identities. This makes state assembly and identity hashing
silently incorrect instead of surfacing actionable filesystem failures.
Code

src/observability/dashboard/state/identity.js[R26-38]

+function canonicalPath(path) {
+  const resolved = resolve(path);
+  const cached = CANONICAL_PATH_CACHE.get(resolved);
+  if (cached) return cached;
+  try {
+    const canonical = realpathSync(resolved);
+    // Only successful resolutions are cached — a directory that appears
+    // later must get its real identity then, not a frozen lexical fallback.
+    CANONICAL_PATH_CACHE.set(resolved, canonical);
+    return canonical;
+  } catch {
+    return resolved;
+  }
Relevance

●●● Strong

Team previously accepted tightening FS error handling to only swallow ENOENT; broad catch+fallback
discouraged (PR #143).

PR-#143

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Both canonicalization helpers introduced by the PR use a bare catch { return resolved; }, which
collapses all realpath failures (including non-ENOENT cases) into the same silent fallback; this can
hide real filesystem problems and defeat the dedup/identity guarantees the PR is trying to
establish.

src/observability/dashboard/state/identity.js[18-39]
src/observability/dashboard/state/roots.js[7-21]
PR-#143

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`canonicalPath()` (sync) and `canonicalRoot()` (async) currently swallow *all* errors from `realpathSync`/`realpath` and treat them as “missing path” by returning the lexical `resolve()` result. This hides important operational failures (e.g., `EACCES`, `EIO`, `ELOOP`) and can bring back duplicate root/project identities when realpath fails for reasons other than nonexistence.

## Issue Context
The PR intent is “missing directories degrade to lexical form (no throw)”. That intent is narrower than “any realpath failure degrades”.

## Fix Focus Areas
- src/observability/dashboard/state/identity.js[26-38]
- src/observability/dashboard/state/roots.js[14-20]

Suggested implementation direction:
- Catch the error object and only fall back to `resolved` for expected “missing” cases (typically `err.code === 'ENOENT'`, and optionally `ENOTDIR`).
- For other error codes, either rethrow (fail fast) or at least `console.warn`/`console.error` with the error code and path so operators can diagnose why canonicalization failed.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Magic timeout numbers in test ✓ Resolved 📜 Skill insight ⚙ Maintainability
Description
The new browser E2E test uses hardcoded timeout values (15_000, 10_000) instead of named
constants, reducing clarity and maintainability. This violates the requirement to replace magic
numbers with named constants.
Code

tests/browser/dashboard-494-approval-dedup.test.js[R55-104]

+    const timer = setTimeout(() => {
+      if (!settled) { settled = true; child.kill('SIGKILL'); rejectPromise(new Error(`server did not boot\n${out}`)); }
+    }, 15_000);
+    child.stdout.on('data', (chunk) => {
+      out += chunk;
+      const match = out.match(/Dashboard: http:\/\/localhost:(\d+)/);
+      if (match && !settled) {
+        settled = true;
+        clearTimeout(timer);
+        resolvePromise({ baseUrl: `http://127.0.0.1:${match[1]}`, stop: () => child.kill('SIGKILL') });
+      }
+    });
+    child.stderr.on('data', (chunk) => { out += chunk; });
+    child.on('exit', () => { if (!settled) { settled = true; clearTimeout(timer); rejectPromise(new Error(`server exited\n${out}`)); } });
+  });
+}
+
+let browser = null;
+test.before(async () => {
+  if (!chromium) return;
+  try { browser = await chromium.launch({ headless: true }); } catch { browser = null; }
+});
+test.after(async () => { if (browser) await browser.close(); });
+
+function browserTest(name, seed, body) {
+  test(name, async (t) => {
+    if (!browser) { t.skip('Chromium not installed — run `npx playwright-core install chromium`'); return; }
+    const root = mkdtempSync(join(tmpdir(), 'rstack-browser-494-'));
+    let server = null;
+    const context = await browser.newContext();
+    const page = await context.newPage();
+    const pageErrors = [];
+    page.on('pageerror', (err) => pageErrors.push(String(err)));
+    try {
+      await seed(root);
+      server = await startServer(root);
+      await body({ page, server, t });
+      assert.deepEqual(pageErrors, [], 'no uncaught page errors');
+    } finally {
+      await context.close();
+      if (server) server.stop();
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+}
+
+async function gotoDashboard(page, server) {
+  await page.goto(server.baseUrl, { waitUntil: 'networkidle' });
+  await page.waitForFunction(() => window.STATE != null, null, { timeout: 10_000 });
+}
Relevance

●●● Strong

Team has accepted replacing magic numbers with named constants in tests/code (e.g., PR #490).

PR-#490

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1400588 requires literal timeout values to be replaced with named constants. The
test introduces setTimeout(..., 15_000) and a Playwright timeout: 10_000 literal.

tests/browser/dashboard-494-approval-dedup.test.js[55-57]
tests/browser/dashboard-494-approval-dedup.test.js[101-104]
Skill: code-patterns

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Hardcoded timeout values are used directly in the test (`15_000`, `10_000`).

## Issue Context
Timeouts should be expressed via named constants to document intent and make future tuning safer.

## Fix Focus Areas
- tests/browser/dashboard-494-approval-dedup.test.js[55-57]
- tests/browser/dashboard-494-approval-dedup.test.js[101-104]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Stale canonical path cache ✓ Resolved 🐞 Bug ≡ Correctness
Description
identity.js memoizes successful realpath results in a module-level Map with no invalidation, so if a
root path is a symlink that gets repointed while the dashboard process stays up, identities can keep
using the old target. This can mis-associate project/run identity across snapshots in long-lived
processes that swap symlinked directories.
Code

src/observability/dashboard/state/identity.js[R24-35]

+const CANONICAL_PATH_CACHE = new Map();
+
+function canonicalPath(path) {
+  const resolved = resolve(path);
+  const cached = CANONICAL_PATH_CACHE.get(resolved);
+  if (cached) return cached;
+  try {
+    const canonical = realpathSync(resolved);
+    // Only successful resolutions are cached — a directory that appears
+    // later must get its real identity then, not a frozen lexical fallback.
+    CANONICAL_PATH_CACHE.set(resolved, canonical);
+    return canonical;
Relevance

●● Moderate

No prior reviews found about invalidating module-level realpath caches or handling symlink
retargeting in long-lived processes.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The PR adds a persistent Map cache for canonical paths and then uses canonicalPath() to derive
project descriptors and run scope keys; without invalidation, a symlink target change after the
first lookup will not be reflected in later identity computations.

src/observability/dashboard/state/identity.js[18-39]
src/observability/dashboard/state/identity.js[73-111]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`CANONICAL_PATH_CACHE` is module-scoped and never invalidated/expired. If `resolved` is a symlink path and its target changes during the lifetime of the server, `canonicalPath(resolved)` will continue returning the old cached canonical path, causing `resolveProjectDescriptor()`, `decorateRunIdentity()`, and `runScopeKey()` to compute identity from stale filesystem topology.

## Issue Context
The PR comment says memoization is for performance, but the cache lifetime is currently the entire process.

## Fix Focus Areas
- src/observability/dashboard/state/identity.js[24-39]
- src/observability/dashboard/state/identity.js[73-111]

Suggested implementation direction (pick one):
- Make the cache per snapshot/build: move the cache into `decorateRunIdentity`/`buildFullState` scope and pass a memoized canonicalizer down.
- Or implement a bounded LRU/TTL (e.g., max N entries and/or periodic clear) so long-lived processes eventually refresh.
- Or validate cached entries via `lstatSync`/inode+mtime and refresh when the symlink or target changes.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

4. Vague memoization performance claim ✓ Resolved 📜 Skill insight ✧ Quality
Description
A new comment claims the memoization is acceptable because the roots set is “tiny and stable”, but
provides no concrete numbers, making the performance rationale non-auditable.
Code

src/observability/dashboard/state/identity.js[R18-23]

+// #505: identity is a function of the real filesystem node, never of the
+// path spelling that reached us — macOS's /tmp and /var are symlinks into
+// /private, so lexical resolve() hashed the same directory into two project
+// ids. A missing path degrades to its resolved lexical form (identity for
+// vanished roots stays stable instead of throwing). Memoized: this runs per
+// run per snapshot and the distinct-roots set is tiny and stable.
Relevance

●● Moderate

No historical evidence found for rejecting vague performance rationale comments; team often leaves
explanatory comments unquantified.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1400044 disallows vague performance statements without concrete numbers. The added
comment describes the distinct-roots set as “tiny and stable” without quantification.

src/observability/dashboard/state/identity.js[18-23]
Skill: code-review-pr

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
New performance-related comments should include concrete numbers rather than vague statements. The memoization note does not quantify expected root counts or call frequency.

## Issue Context
Concrete metrics help reviewers validate performance tradeoffs and avoid ambiguous guidance.

## Fix Focus Areas
- src/observability/dashboard/state/identity.js[18-23]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. canonicalPath() uses realpathSync 📜 Skill insight ➹ Performance
Description
The new canonicalPath() implementation performs synchronous filesystem I/O via realpathSync(),
which can block the event loop during state assembly. This violates the requirement to use
async/await for I/O operations.
Code

src/observability/dashboard/state/identity.js[R24-38]

+const CANONICAL_PATH_CACHE = new Map();
+
+function canonicalPath(path) {
+  const resolved = resolve(path);
+  const cached = CANONICAL_PATH_CACHE.get(resolved);
+  if (cached) return cached;
+  try {
+    const canonical = realpathSync(resolved);
+    // Only successful resolutions are cached — a directory that appears
+    // later must get its real identity then, not a frozen lexical fallback.
+    CANONICAL_PATH_CACHE.set(resolved, canonical);
+    return canonical;
+  } catch {
+    return resolved;
+  }
Relevance

● Weak

Repo frequently uses sync fs in state/identity (statSync/readFileSync); no precedent demanding
async-only I/O here.

PR-#317

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1400621 requires I/O to be async/await-based. The added canonicalPath() function
calls realpathSync(resolved) synchronously.

src/observability/dashboard/state/identity.js[24-39]
Skill: mcp-builder

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`canonicalPath()` calls `realpathSync()`, introducing synchronous filesystem I/O.

## Issue Context
Compliance requires I/O operations to use async/await to avoid blocking the Node.js event loop.

## Fix Focus Areas
- src/observability/dashboard/state/identity.js[24-39]
- src/observability/dashboard/state/identity.js[73-111]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. approvalDecisionKey missing JSDoc 📜 Skill insight ⚙ Maintainability
Description
The new exported function approvalDecisionKey lacks JSDoc documentation with @param, @returns,
and @throws tags, which reduces API clarity and makes it harder to use safely across the codebase.
Code

src/observability/dashboard/state/approvals.js[R37-44]

+// #494: one human decision is identified by (runId, taskId, artifact) — the
+// semantic key — never by the queue entry's id spelling. A queue card whose
+// id is not the recomputed canonical approvalQueueId (pre-#274 runs, hand-
+// authored entries, the canonical fixtureBlockedRun card) must still count
+// as the same decision, or every surface shows it twice.
+export function approvalDecisionKey(entry) {
+  return [entry?.runId ?? '', entry?.taskId ?? '', entry?.artifact ?? ''].join('::');
+}
Relevance

● Weak

Similar doc/JSDoc requests were rejected for dashboard state exports (PR #317); enforcement
inconsistent despite partial acceptance in #465.

PR-#317
PR-#465

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1400405 requires exported functions to have JSDoc including @param, @returns,
and @throws. The new exported approvalDecisionKey(entry) is introduced without such a JSDoc
block.

src/observability/dashboard/state/approvals.js[37-44]
Skill: code-patterns

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Exported functions added/changed in this PR should include JSDoc with `@param`, `@returns`, and (when applicable) `@throws` tags. `approvalDecisionKey` is exported but currently only has a non-tag comment.

## Issue Context
This rule is intended to keep public APIs self-documenting and consistent.

## Fix Focus Areas
- src/observability/dashboard/state/approvals.js[37-44]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
7. Regression tests missing attribution 📜 Skill insight ⚙ Maintainability
Description
New regression tests include issue context but omit required attribution fields (found date and QA
report path), weakening traceability for future audits and debugging.
Code

tests/approval-dedup-494.test.js[R1-17]

+/**
+ * #494 — one human decision renders as ONE pending item everywhere.
+ *
+ * The event-derived gap-filler (approvalRequestsFromBlockedGates, #274) used
+ * to dedup against queue entries by raw id only, so a queue card whose id is
+ * not the recomputed canonical approvalQueueId — pre-#274 runs, hand-authored
+ * entries, the canonical fixtureBlockedRun card — produced a duplicate
+ * pending card, an inflated topbar count, and a doubled Studio gates rail.
+ *
+ * Dedup is now semantic: (runId, taskId, artifact). The queue-backed entry
+ * wins (it is the actionable record); the derived entry remains strictly a
+ * gap-filler. The semantic Studio's governance deck likewise counts one item
+ * per decision: a blocked-gate record is suppressed when a pending approval
+ * covers the same run/task/artifact.
+ *
+ * owner: RStack developed by Richardson Gunde
+ */
Relevance

● Weak

Regression-test attribution metadata (found date/QA path) suggestions were explicitly rejected
recently (PR #475).

PR-#475

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1400067 requires regression tests to include attribution details (issue ID,
description, date found, QA report path). The new test headers mention #494/#505 and describe
the regression, but do not include a found date or QA report path.

tests/approval-dedup-494.test.js[1-17]
tests/source-roots-canonical-505.test.js[1-11]
tests/browser/dashboard-494-approval-dedup.test.js[1-14]
Skill: qa-testing

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Regression test files should include an attribution comment block containing: issue ID, what broke, date found, and QA report path. The new tests include issue ID/description but not the date found or QA report reference.

## Issue Context
This is meant to preserve provenance for regressions and link them back to the originating investigation.

## Fix Focus Areas
- tests/approval-dedup-494.test.js[1-17]
- tests/source-roots-canonical-505.test.js[1-11]
- tests/browser/dashboard-494-approval-dedup.test.js[1-14]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread src/observability/dashboard/state/identity.js Outdated
Comment thread tests/browser/dashboard-494-approval-dedup.test.js
Comment thread src/observability/dashboard/state/identity.js
Comment thread src/observability/dashboard/state/identity.js Outdated

@strix-security strix-security Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Strix flagged 3 new security findings below. See the pinned summary comment for the full PR status.

Comment on lines +42 to +44
export function approvalDecisionKey(entry) {
return [entry?.runId ?? '', entry?.taskId ?? '', entry?.artifact ?? ''].join('::');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Cross-project approval suppression from run-scoped semantic dedup

Severity: LOW · CWE-863

The PR changes approval deduplication and Studio governance suppression to key decisions only by runId, taskId, and artifact. This ignores the repository/root dimension even though the dashboard explicitly supports identical runId values across different project roots. In a live local reproduction with two distinct roots sharing the same runId and the same (taskId, artifact), a queue-backed pending approval in one root suppresses the other root's blocked gate and derived pending approval in global state. When the second run is selected in Studio, its governance deck shows the first project's approval instead of the second project's blocked gate. This hides a real pending governance decision across projects and breaks approval integrity in multi-project views.

Suggested change
export function approvalDecisionKey(entry) {
return [entry?.runId ?? '', entry?.taskId ?? '', entry?.artifact ?? ''].join('::');
}
export function approvalDecisionKey(entry) {
return [
entry?.projectId ?? entry?.projectRoot ?? '',
entry?.runId ?? '',
entry?.taskId ?? '',
entry?.artifact ?? '',
].join('::');
}
Prompt to fix with AI
This is a security vulnerability found during a code review.

Vulnerability: Cross-project approval suppression from run-scoped semantic dedup
Severity: LOW
CWE: CWE-863

The PR changes approval deduplication and Studio governance suppression to key decisions only by `runId`, `taskId`, and `artifact`. This ignores the repository/root dimension even though the dashboard explicitly supports identical `runId` values across different project roots. In a live local reproduction with two distinct roots sharing the same `runId` and the same `(taskId, artifact)`, a queue-backed pending approval in one root suppresses the other root's blocked gate and derived pending approval in global state. When the second run is selected in Studio, its governance deck shows the first project's approval instead of the second project's blocked gate. This hides a real pending governance decision across projects and breaks approval integrity in multi-project views.

Location: src/observability/dashboard/state/approvals.js:42-44
Context: Scope semantic approval dedup to the project/root
```
// Before:
export function approvalDecisionKey(entry) {
  return [entry?.runId ?? '', entry?.taskId ?? '', entry?.artifact ?? ''].join('::');
}
// After:
export function approvalDecisionKey(entry) {
  return [
    entry?.projectId ?? entry?.projectRoot ?? '',
    entry?.runId ?? '',
    entry?.taskId ?? '',
    entry?.artifact ?? '',
  ].join('::');
}
```

Location: src/observability/dashboard/state/approvals.js:60-62
Context: Pass project scope into blocked-gate dedup
```
// Before:
      const id = approvalQueueId({ runId: gate.runId, taskId: gate.taskId, artifact });
      const key = approvalDecisionKey({ runId: gate.runId, taskId: gate.taskId, artifact });
      if (existing.has(`id:${id}`) || existing.has(`key:${key}`)) continue;
// After:
      const id = approvalQueueId({ runId: gate.runId, taskId: gate.taskId, artifact });
      const key = approvalDecisionKey({
        projectId: gate.projectId,
        projectRoot: gate.projectRoot,
        runId: gate.runId,
        taskId: gate.taskId,
        artifact,
      });
      if (existing.has(`id:${id}`) || existing.has(`key:${key}`)) continue;
```

Location: src/observability/dashboard/state/studio.js:354-367
Context: Prevent Studio governance suppression across projects
```
// Before:
  const pendingApprovals = (state?.approvals ?? [])
    .filter((item) => (!item?.runId || item.runId === runId) && String(item?.status ?? '').toLowerCase() === 'pending');
  // #494: one decision = one governance item. A blocked-gate record whose
  // every missing artifact is covered by a pending approval for the same
  // task is the decision that approval card already represents — listing
  // both read as "holding 2 gate(s)" for a single human call. Gates with no
  // covering approval (or no missing list) still surface as observed truth.
  const covered = new Set(pendingApprovals.map((item) => `${safeId(item.taskId) ?? ''}::${item.artifact ?? ''}`));
  const gates = (state?.blockedGates ?? [])
    .filter((item) => !item?.runId || item.runId === runId)
    .filter((item) => {
      const missing = Array.isArray(item?.missing) && item.missing.length ? item.missing : null;
      if (!missing) return true;
      return !missing.every((artifact) => covered.has(`${safeId(item.taskId) ?? ''}::${artifact}`));
// After:
  const pendingApprovals = (state?.approvals ?? [])
    .filter((item) => (!item?.runId || item.runId === runId) && String(item?.status ?? '').toLowerCase() === 'pending');
  // #494: one decision = one governance item. A blocked-gate record whose
  // every missing artifact is covered by a pending approval for the same
  // task is the decision that approval card already represents — listing
  // both read as "holding 2 gate(s)" for a single human call. Gates with no
  // covering approval (or no missing list) still surface as observed truth.
  const covered = new Set(pendingApprovals.map((item) => `${item.projectId ?? item.projectRoot ?? ''}::${safeId(item.taskId) ?? ''}::${item.artifact ?? ''}`));
  const gates = (state?.blockedGates ?? [])
    .filter((item) => !item?.runId || item.runId === runId)
    .filter((item) => {
      const missing = Array.isArray(item?.missing) && item.missing.length ? item.missing : null;
      if (!missing) return true;
      const scope = item.projectId ?? item.projectRoot ?? '';
      return !missing.every((artifact) => covered.has(`${scope}::${safeId(item.taskId) ?? ''}::${artifact}`));
```

How to fix:
Include project identity in both semantic keys. The approval dedup key should incorporate a stable root/project discriminator such as `projectRoot` or `projectId` in addition to `runId`, `taskId`, and `artifact`. The Studio suppression set should likewise scope coverage by the same discriminator so one project's pending approval cannot cover another project's blocked gate. Preserve the existing legacy-id behavior, but only within the same project/root boundary.

Please fix this vulnerability. If you propose a fix, make it concise and minimal.

React 👍 / 👎 to tune Strix for this repo. A repo collaborator (or the PR author) can resolve this thread to dismiss the finding.

Comment on lines 60 to +62
const id = approvalQueueId({ runId: gate.runId, taskId: gate.taskId, artifact });
if (existing.has(id)) continue;
existing.add(id);
const key = approvalDecisionKey({ runId: gate.runId, taskId: gate.taskId, artifact });
if (existing.has(`id:${id}`) || existing.has(`key:${key}`)) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pass project scope into blocked-gate dedup

Suggested change
const id = approvalQueueId({ runId: gate.runId, taskId: gate.taskId, artifact });
if (existing.has(id)) continue;
existing.add(id);
const key = approvalDecisionKey({ runId: gate.runId, taskId: gate.taskId, artifact });
if (existing.has(`id:${id}`) || existing.has(`key:${key}`)) continue;
const id = approvalQueueId({ runId: gate.runId, taskId: gate.taskId, artifact });
const key = approvalDecisionKey({
projectId: gate.projectId,
projectRoot: gate.projectRoot,
runId: gate.runId,
taskId: gate.taskId,
artifact,
});
if (existing.has(`id:${id}`) || existing.has(`key:${key}`)) continue;

Comment on lines +354 to +367
const pendingApprovals = (state?.approvals ?? [])
.filter((item) => (!item?.runId || item.runId === runId) && String(item?.status ?? '').toLowerCase() === 'pending');
// #494: one decision = one governance item. A blocked-gate record whose
// every missing artifact is covered by a pending approval for the same
// task is the decision that approval card already represents — listing
// both read as "holding 2 gate(s)" for a single human call. Gates with no
// covering approval (or no missing list) still surface as observed truth.
const covered = new Set(pendingApprovals.map((item) => `${safeId(item.taskId) ?? ''}::${item.artifact ?? ''}`));
const gates = (state?.blockedGates ?? [])
.filter((item) => !item?.runId || item.runId === runId)
.filter((item) => {
const missing = Array.isArray(item?.missing) && item.missing.length ? item.missing : null;
if (!missing) return true;
return !missing.every((artifact) => covered.has(`${safeId(item.taskId) ?? ''}::${artifact}`));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Prevent Studio governance suppression across projects

Suggested change
const pendingApprovals = (state?.approvals ?? [])
.filter((item) => (!item?.runId || item.runId === runId) && String(item?.status ?? '').toLowerCase() === 'pending');
// #494: one decision = one governance item. A blocked-gate record whose
// every missing artifact is covered by a pending approval for the same
// task is the decision that approval card already represents — listing
// both read as "holding 2 gate(s)" for a single human call. Gates with no
// covering approval (or no missing list) still surface as observed truth.
const covered = new Set(pendingApprovals.map((item) => `${safeId(item.taskId) ?? ''}::${item.artifact ?? ''}`));
const gates = (state?.blockedGates ?? [])
.filter((item) => !item?.runId || item.runId === runId)
.filter((item) => {
const missing = Array.isArray(item?.missing) && item.missing.length ? item.missing : null;
if (!missing) return true;
return !missing.every((artifact) => covered.has(`${safeId(item.taskId) ?? ''}::${artifact}`));
const pendingApprovals = (state?.approvals ?? [])
.filter((item) => (!item?.runId || item.runId === runId) && String(item?.status ?? '').toLowerCase() === 'pending');
// #494: one decision = one governance item. A blocked-gate record whose
// every missing artifact is covered by a pending approval for the same
// task is the decision that approval card already represents — listing
// both read as "holding 2 gate(s)" for a single human call. Gates with no
// covering approval (or no missing list) still surface as observed truth.
const covered = new Set(pendingApprovals.map((item) => `${item.projectId ?? item.projectRoot ?? ''}::${safeId(item.taskId) ?? ''}::${item.artifact ?? ''}`));
const gates = (state?.blockedGates ?? [])
.filter((item) => !item?.runId || item.runId === runId)
.filter((item) => {
const missing = Array.isArray(item?.missing) && item.missing.length ? item.missing : null;
if (!missing) return true;
const scope = item.projectId ?? item.projectRoot ?? '';
return !missing.every((artifact) => covered.has(`${scope}::${safeId(item.taskId) ?? ''}::${artifact}`));

…ache

Strix (CWE-863, LOW — live-reproduced): the semantic decision key omitted
the project dimension, so equal (runId, taskId, artifact) tuples in two
different roots cross-suppressed — a queue card in project A could swallow
project B's blocked gate. approvalDecisionKey now leads with
projectId ?? projectRoot, the gate-side key passes the gate's project, and
the Studio deck matches items to the run by project as well as run id for
BOTH listing and gate suppression (legacy records without project info
keep run-id matching). Two cross-project regression tests, verified
failing first.

Qodo (reliability, PR #143 precedent): canonicalPath/canonicalRoot
swallowed every realpath failure — EACCES/ELOOP/EIO now log to stderr
before degrading to the lexical form; only not-found stays quiet. The
memoization cache is removed outright (realpath is microseconds, the root
set is tiny, and a cache would freeze identities across symlink retargets)
— which also resolves the staleness and vague-perf-claim review notes.

Qodo (convention, PR #490 precedent): the browser test's timeouts are
named constants.

Refs #494 #505 (PR #509 review follow-up).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@richard-devbot

Copy link
Copy Markdown
Owner Author

Review findings addressed in d463c18 — all three reviewers' items:

  • Strix CWE-863 (accepted, real — thank you for the live repro): approvalDecisionKey now leads with projectId ?? projectRoot; the gate-side dedup passes the gate's project; the Studio deck matches by project for both listing AND gate suppression (a foreign project's approval neither lists nor covers here). Two cross-project regression tests added, verified failing before the fix.
  • Qodo bug 2 (accepted, [codex] Add backend control plane inventory #143 precedent): realpath fallback is now ENOENT/ENOTDIR-only-quiet; EACCES/ELOOP/EIO log to stderr before degrading, in both identity.js and roots.js.
  • Qodo inline 3+7 (resolved by simplification): the memoization cache is deleted — realpath is microseconds on a tiny root set, and no cache means no staleness across symlink retargets.
  • Qodo bug 1 (accepted, Fix #478: required Scientist execution fails closed, never PASS on WARN #490 precedent): browser-test timeouts are named constants.

Suite 1722/1722, lint/typecheck clean. — Claude Main

@richard-devbot
richard-devbot merged commit bd26a38 into main Jul 31, 2026
10 checks passed
richard-devbot pushed a commit that referenced this pull request Jul 31, 2026
Qodo (convention, #490/#509/#511 precedent): the settle guard's 120ms
slack is now COUNT_UP_SETTLE_BUFFER_MS with its rationale.

Refs #516 (PR #520 review follow-up).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants