Skip to content

[AI-2326] Gate the work-items nudge on the tenant's plan - #976

Merged
George-Payne merged 4 commits into
mainfrom
georgepayne/ai-2326-plan-entitlement-header
Sep 16, 2026
Merged

George-Payne merged 4 commits into
mainfrom
georgepayne/ai-2326-plan-entitlement-header

Conversation

@George-Payne

Copy link
Copy Markdown
Member

What

On the Free plan every /api/work-items route 403s work_items_not_in_plan, but the SessionStart work-items nudge still told the agent to call declare_work_item every session. The bounced tool call was, for a Free user, the only visible kcap interaction — it reads as a payment nag rather than a product.

This gates the nudge on the tenant's plan.

How

The plan arrives the way the server's own version already does — a passive response header captured into a per-server file under the config root:

  • X-Kcap-Plan: work_items=0,projects=1 — comma-separated key=flag, 0 denies.
  • PlanEntitlementCaptureHandler sits beside ServerVersionCaptureHandler on the Default/Hook/Memory lanes (not Anonymous — same reasoning as the version capture: those requests go to a server the caller has not adopted).
  • PlanEntitlementStore mirrors ServerVersionStore: one file per normalized server URL, atomic write, in-process dedupe.
  • WorkItemsNudgeEmitter.Resolve takes the cached answer and returns null when Work Items are denied.

Why a header and not the session-start response body: eight of the nine harnesses discard that body — they post through AgentHookPoster and only read the status. But they all send on a lane the capture handler is registered against, so a header needs no per-harness plumbing and no SessionStartAck field. One answer, shared by every harness on the machine.

The nudge still takes no round trip and no lease of its own, so it stays composable after the lease-gated memory/guidelines lane exactly as before — a cache read preserves that invariant where a fetch would not.

Failure posture

Fail-open in every direction, so this can only remove a nudge that today fires regardless:

Situation Result
No header (older server, stripped by an intermediary) last known answer kept; nudges if none
Server unreachable / offline nudges
Corrupt or unreadable cache nudges
Answer older than 7 days nudges
Malformed header, or one bad pair that pair skipped, rest honoured

The staleness horizon is only reachable when a server stops sending the header — every authenticated response refreshes it otherwise. Without it, a rollback would suppress a paying tenant's nudges permanently with no way back.

Cost of the cache being one response behind: at most one stale nudge across a plan change. Claude reads it after the session-start response has landed, so it is fresh in-session; the other eight write stdout before awaiting their POST (deliberately — see KiroHookCommand), so they read the previous request's answer.

Not in this PR

  • The server half — emitting X-Kcap-Plan from ITenantPlan. Follows in kcap-server with the submodule pin bump. Until it merges this is inert: no header, no behaviour change.
  • projects and analytics, which have the same shape one layer quieter (registered on Free, refused on Free) but nothing nudging an agent toward them. The header carries the whole entitlement set so they cost nothing later.

Test

PlanEntitlementsTests (parse/render, caps, fail-open), PlanEntitlementStoreTests (round-trip, normalization, staleness, corruption), PlanEntitlementCaptureHandlerTests (capture, absent header, upgrade clears a denial), plus the plan-gate cases in WorkItemsNudgeEmitterTests. 24 + 12 green locally.

Linear: AI-2326 — customer report from the moosegoose tenant, independently reproduced in dogfooding.

On the Free plan every /api/work-items route 403s work_items_not_in_plan,
but the SessionStart nudge still told the agent to call declare_work_item
each session, so the only visible kcap interaction was a bounced tool call
that reads as a payment nag.

The plan arrives the way the server's own version already does: a passive
X-Kcap-Plan response header captured on every authenticated lane into a
per-server file under the config root. That is what makes one answer serve
all nine harnesses — eight of them discard the session-start response body,
but they all send on a lane the handler is registered against, so no
per-harness plumbing is needed.

Fail-open throughout: an absent header, an unreachable server or a stale
answer allows the nudge, so this can only ever remove one that today fires
regardless. A denial that stops being re-heard expires, so a rolled-back
server cannot suppress a paying tenant's nudges permanently.
@George-Payne George-Payne self-assigned this Sep 16, 2026
@linear-code

linear-code Bot commented Sep 16, 2026

Copy link
Copy Markdown

AI-2326

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Gate work-items nudges on cached tenant plan entitlements

🐞 Bug fix ✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Capture tenant plan entitlements from authenticated response headers into a per-server cache.
• Suppress work-items nudges when cached entitlements deny the feature.
• Preserve fail-open behavior for missing, malformed, stale, or unreadable plan data.
Diagram

graph TD
  A["Authenticated server"] -->|"X-Kcap-Plan"| B["Capture handler"] --> C["Plan parser"] --> D["Per-server cache"] --> E["Harness hooks"] --> F{"Work Items allowed?"} -->|"Yes"| G["Session nudge"]
  F -->|"No"| H["Nudge suppressed"]
Loading
High-Level Assessment

The passive response-header approach is appropriate and mirrors the existing server-version capture design. It avoids live SessionStart requests, preserves lease-independent nudge composition, and shares one answer across harnesses that discard response bodies; response-body plumbing and per-session entitlement fetches would add duplication or latency without improving the gate.

Files changed (20) +555 / -29

Enhancement (5) +229 / -6
CapacitorHttpServices.csRegister plan capture on authenticated HTTP lanes +10/-6

Register plan capture on authenticated HTTP lanes

• Registers PlanEntitlementCaptureHandler on the Default, Hook, and Memory clients outside unauthorized recovery. The Anonymous lane remains excluded because its target may not be the configured server.

src/Capacitor.Cli.Core/Http/CapacitorHttpServices.cs

PlanEntitlementCaptureHandler.csCapture plan entitlements from responses +32/-0

Capture plan entitlements from responses

• Adds a best-effort delegating handler that records present X-Kcap-Plan headers without altering responses. Missing headers preserve the previously cached answer.

src/Capacitor.Cli.Core/Http/PlanEntitlementCaptureHandler.cs

HttpClientExtensions.csDefine the plan response header +7/-0

Define the plan response header

• Adds the X-Kcap-Plan header constant and documents its comma-separated entitlement format and fail-open semantics.

src/Capacitor.Cli.Core/HttpClientExtensions.cs

PlanEntitlementStore.csPersist per-server plan entitlements +106/-0

Persist per-server plan entitlements

• Adds an atomic, normalized per-server JSON cache with in-process write deduplication. Missing, corrupt, unreadable, or older-than-seven-days entries resolve to permissive unknown entitlements.

src/Capacitor.Cli.Core/PlanEntitlementStore.cs

PlanEntitlements.csParse and query plan entitlement denials +74/-0

Parse and query plan entitlement denials

• Introduces feature keys and a bounded, case-insensitive parser that retains only explicit denials. Malformed pairs and unrecognized flags fail open while valid denials remain enforceable.

src/Capacitor.Cli.Core/PlanEntitlements.cs

Bug fix (10) +28 / -19
AntigravityHookCommand.csGate Antigravity work-items nudges +1/-1

Gate Antigravity work-items nudges

• Loads cached plan entitlements before resolving the Antigravity SessionStart work-items nudge.

src/Capacitor.Cli/Commands/Harness/AntigravityHookCommand.cs

ClaudeHookCommand.csGate Claude work-items nudges +1/-1

Gate Claude work-items nudges

• Passes the configured server's cached plan into Claude's work-items nudge resolution.

src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs

CodexHookCommand.csGate Codex work-items nudges +1/-1

Gate Codex work-items nudges

• Uses cached tenant entitlements when composing Codex SessionStart nudges.

src/Capacitor.Cli/Commands/Harness/CodexHookCommand.cs

CopilotHookCommand.csGate Copilot work-items nudges +1/-1

Gate Copilot work-items nudges

• Consults the per-server plan cache before including the Copilot work-items nudge.

src/Capacitor.Cli/Commands/Harness/CopilotHookCommand.cs

CursorHookCommand.csGate Cursor work-items nudges +1/-1

Gate Cursor work-items nudges

• Adds cached entitlement data to Cursor's work-items nudge decision after profile resolution.

src/Capacitor.Cli/Commands/Harness/CursorHookCommand.cs

GeminiHookCommand.csGate Gemini work-items nudges +1/-1

Gate Gemini work-items nudges

• Applies the cached tenant plan when composing Gemini SessionStart output.

src/Capacitor.Cli/Commands/Harness/GeminiHookCommand.cs

KiroHookCommand.csGate Kiro work-items nudges +1/-1

Gate Kiro work-items nudges

• Reads cached plan entitlements inside Kiro's existing nudge resolution function.

src/Capacitor.Cli/Commands/Harness/KiroHookCommand.cs

OpenCodeHookCommand.csGate OpenCode work-items nudges +1/-1

Gate OpenCode work-items nudges

• Uses cached plan entitlements when composing eligible OpenCode SessionStart nudges.

src/Capacitor.Cli/Commands/Harness/OpenCodeHookCommand.cs

PiHookCommand.csGate Pi work-items nudges +1/-1

Gate Pi work-items nudges

• Passes the configured server's cached plan into Pi work-items nudge resolution.

src/Capacitor.Cli/Commands/Harness/PiHookCommand.cs

WorkItemsNudgeEmitter.csSuppress nudges for denied Work Items plans +19/-10

Suppress nudges for denied Work Items plans

• Extends nudge resolution with a PlanEntitlements argument and returns no fragment when Work Items are explicitly denied. Unknown plans remain permissive, preserving offline and older-server behavior.

src/Capacitor.Cli/WorkItemsNudgeEmitter.cs

Tests (4) +297 / -3
PlanEntitlementCaptureHandlerTests.csTest passive plan header capture +75/-0

Test passive plan header capture

• Verifies capture, missing-header preservation, unknown defaults, and clearing an earlier denial with an allowing response.

test/Capacitor.Cli.Core.Tests.Unit/PlanEntitlementCaptureHandlerTests.cs

PlanEntitlementStoreTests.csTest entitlement cache lifecycle +94/-0

Test entitlement cache lifecycle

• Covers round trips, URL normalization, server isolation, upgrades, staleness, blank inputs, and corrupt cache files.

test/Capacitor.Cli.Core.Tests.Unit/PlanEntitlementStoreTests.cs

PlanEntitlementsTests.csTest entitlement parsing and rendering +99/-0

Test entitlement parsing and rendering

• Covers denial parsing, malformed input, case handling, unknown features, deterministic rendering, entry bounds, and invalid control characters.

test/Capacitor.Cli.Core.Tests.Unit/PlanEntitlementsTests.cs

WorkItemsNudgeTests.csTest plan-gated nudge resolution +29/-3

Test plan-gated nudge resolution

• Updates existing resolver calls with permissive unknown plans and verifies that only a Work Items denial suppresses the nudge.

test/Capacitor.Cli.Tests.Unit/SessionStartMemory/WorkItemsNudgeTests.cs

Documentation (1) +1 / -1
README.mdDocument plan-gated work-items nudges +1/-1

Document plan-gated work-items nudges

• Explains that work-items nudges require tenant plan entitlement and use the last cached X-Kcap-Plan response. Documents fail-open behavior and the possibility of one stale nudge after a plan change.

README.md

check-linear-ids.sh forbids them outright under src/, and suppression is
for synthetic test data only — so the comments carry the behaviour instead
of the ticket reference.
@qodo-code-review

qodo-code-review Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (2) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Tenants inherit another tenant's plan 🐞 Bug ≡ Correctness
Description
PlanEntitlementStore.Set and Get derive the cache key solely from the normalized server URL even
though authentication tokens and tenant selections are profile-specific. When two profiles
authenticate to different tenants on the same server, the last profile to receive a plan header
controls whether both profiles receive the work-items nudge.
Code

src/Capacitor.Cli.Core/PlanEntitlementStore.cs[R47-48]

+        var key      = Normalize(serverUrl);
+        var path     = PathFor(key, config);
Relevance

●●● Strong

Recent precedent accepts scoping process caches by credential or identity to prevent cross-tenant
reuse.

PR-#495
PR-#387

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Profiles can independently target the same server URL, while credentials are stored and resolved per
profile and sign-in can bind those credentials to a selected organization. The new store discards
that identity and hashes only the URL, so all such profiles under the shared config root resolve the
same entitlement file.

src/Capacitor.Cli.Core/PlanEntitlementStore.cs[40-50]
src/Capacitor.Cli.Core/PlanEntitlementStore.cs[75-89]
src/Capacitor.Cli.Core/Http/PlanEntitlementCaptureHandler.cs[14-25]
src/Capacitor.Cli/Commands/ProfileCommand.cs[56-75]
src/Capacitor.Cli.Core/Auth/TokenStore.cs[65-85]
src/Capacitor.Cli.Core/Auth/TokenStoreCredentials.cs[7-20]
src/Capacitor.Cli.Core/Auth/WorkOSDiscovery.cs[213-230]
src/Capacitor.Cli/Commands/UseCommand.cs[26-43]

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

## Issue description
Plan entitlements are cached only by server URL, allowing profiles authenticated to different tenants on the same server to overwrite and consume each other's plan state.

## Fix Focus Areas
- src/Capacitor.Cli.Core/PlanEntitlementStore.cs[40-50]
- src/Capacitor.Cli.Core/Http/PlanEntitlementCaptureHandler.cs[14-25]
- src/Capacitor.Cli/Commands/Harness/ClaudeHookCommand.cs[777-780]

## Recommended Fix
Include a stable active-profile or authenticated-tenant identity in the cache key, pass that identity to both the capture handler and every nudge-side lookup, and retain server URL as the other partition component. Add coverage proving two profiles on one server keep independent entitlement values.

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



Remediation recommended

2. Cache refresh docs mislead maintainers ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
PlanEntitlementStore.Set returns when the rendered entitlement matches its process-local memo,
before generating and persisting a refreshed seen_at, contrary to the StaleAfter documentation
that every authenticated response refreshes the cached timestamp. In a daemon, MCP process, or other
long-lived client that repeatedly receives the same denial for more than seven days, the original
record ages past the freshness horizon and Get returns Unknown or the fail-open value, while
subsequent identical headers keep taking the early-return path.
Code

src/Capacitor.Cli.Core/PlanEntitlementStore.cs[R25-28]

+    /// <summary>How long a cached answer outlives its last sighting. Never reached while the server
+    /// sends the header — every authenticated response refreshes it — so this bounds one case only:
+    /// a server rolled back to a build that does not send it, where a denial would otherwise suppress
+    /// a paying tenant's nudges permanently with no way back.</summary>
Relevance

●●● Strong

Cache freshness correctness issues are accepted; memoization clearly prevents the documented
timestamp refresh.

PR-#959
PR-#257

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The capture handler invokes Set for every response containing the entitlement header, but
value-only deduplication returns before a new seen_at is generated or persisted when the rendered
value is unchanged. The same store expires records whose persisted timestamp is older than seven
days and returns Unknown, allowing the nudge, so repeated observations in one long-lived process
do not satisfy the StaleAfter comment’s promise of per-response refreshes and leave the
behavior-critical documentation inaccurate.

Rule 2762993: Restrict comments to documenting non-obvious, behavior‑critical constraints
src/Capacitor.Cli.Core/PlanEntitlementStore.cs[25-33]
src/Capacitor.Cli.Core/PlanEntitlementStore.cs[46-50]
src/Capacitor.Cli.Core/PlanEntitlementStore.cs[53-64]
src/Capacitor.Cli.Core/Http/PlanEntitlementCaptureHandler.cs[17-25]
src/Capacitor.Cli.Core/PlanEntitlementStore.cs[46-64]
src/Capacitor.Cli.Core/PlanEntitlementStore.cs[75-91]
src/Capacitor.Cli/WorkItemsNudgeEmitter.cs[39-45]
src/Capacitor.Cli.Core/PlanEntitlementStore.cs[50-64]
src/Capacitor.Cli.Core/PlanEntitlementStore.cs[75-89]
src/Capacitor.Cli.Core/Http/CapacitorHttpServices.cs[53-55]

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

## Issue description
`PlanEntitlementStore.Set` deduplicates identical rendered entitlements before updating `seen_at`, even though the cache documentation promises that every authenticated response refreshes the entry. Consequently, a long-lived process continuously receiving the same denial can let the persisted record expire after seven days, so the implementation and its behavior-critical documentation do not describe the same expiry behavior.

## Fix Focus Areas
- src/Capacitor.Cli.Core/PlanEntitlementStore.cs[25-33]
- src/Capacitor.Cli.Core/PlanEntitlementStore.cs[40-64]
- src/Capacitor.Cli.Core/PlanEntitlementStore.cs[75-89]
- test/Capacitor.Cli.Core.Tests.Unit/PlanEntitlementStoreTests.cs[64-82]

## Recommended Fix
Refresh the persisted `seen_at` timestamp when a valid header is observed even if its rendered entitlement is unchanged. If bounded write deduplication is required, track the last persisted refresh time as well as the value and rewrite sufficiently before `StaleAfter` so repeated valid headers cannot leave a denial stale indefinitely; alternatively, revise the stated expiry contract and related documentation to accurately describe the implemented behavior. Add coverage for a long-lived process repeatedly setting the same entitlement across more than the seven-day staleness horizon and verify that it remains fresh.

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


3. Four comments retain a Linear ticket ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
PlanEntitlementCaptureHandler, PlanEntitlementStore, WorkItemsNudgeEmitter, and a nudge test
embed AI-2326 in newly added comments. These references provide historical ticket context rather
than actionable GitHub follow-up work, so the comments depend on external process metadata.
Code

src/Capacitor.Cli.Core/Http/PlanEntitlementCaptureHandler.cs[6]

+/// SessionStart nudges can skip a tool the plan refuses (AI-2326). Sits beside
Relevance

●●● Strong

Recent precedents consistently accept removing Linear identifiers from source comments.

PR-#526
PR-#358

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The cited rules prohibit Linear-style identifiers and process metadata in comments and permit ticket
references only as actionable open-GitHub-issue TODOs. The cited branch locations contain four
non-actionable AI-2326 references introduced by this PR.

Rule 2270027: Disallow Linear issue identifiers in source code comments
Rule 2897915: Avoid time-sensitive or process-reference metadata in code comments
Rule 2897926: Restrict ticket IDs in comments to open GitHub issues referenced as actionable TODOs
src/Capacitor.Cli.Core/Http/PlanEntitlementCaptureHandler.cs[4-8]
src/Capacitor.Cli.Core/PlanEntitlementStore.cs[10-13]
src/Capacitor.Cli/WorkItemsNudgeEmitter.cs[34-37]
test/Capacitor.Cli.Tests.Unit/SessionStartMemory/WorkItemsNudgeTests.cs[100-101]

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

## Issue description
Four newly added comments include the Linear identifier `AI-2326`, although ticket identifiers in comments must be actionable references to open GitHub issues. Preserve any behavior-critical rationale without retaining the Linear reference.

## Fix Focus Areas
- src/Capacitor.Cli.Core/Http/PlanEntitlementCaptureHandler.cs[4-8]
- src/Capacitor.Cli.Core/PlanEntitlementStore.cs[10-13]
- src/Capacitor.Cli/WorkItemsNudgeEmitter.cs[34-37]
- test/Capacitor.Cli.Tests.Unit/SessionStartMemory/WorkItemsNudgeTests.cs[100-101]

## Recommended Fix
Remove each `AI-2326` reference and rewrite the surrounding text solely in terms of the current behavioral constraint. Do not replace it with another ticket reference unless it is an actionable TODO linked to an open GitHub issue.

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



Informational

4. Two new tests bypass temp injection 📘 Rule violation ▣ Testability
Description
Resolve_returns_null_when_the_plan_denies_work_items and
Resolve_returns_the_nudge_when_the_plan_denies_only_other_features each construct and dispose a
local TempDir. These test methods need temporary configuration files, but they bypass the required
framework-injected public property and add manual lifecycle management to the test class.
Code

test/Capacitor.Cli.Tests.Unit/SessionStartMemory/WorkItemsNudgeTests.cs[R97-100]

+    public async Task Resolve_returns_null_when_the_plan_denies_work_items() {
+        using var tmp = new TempDir();
+
+        // AI-2326: a Free tenant is registered and opted in, but every declare would 403 — so the
Relevance

● Weak

A same-day precedent rejected requiring injected TempDir properties instead of local construction.

PR-#972

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The rule explicitly prohibits new TempDir() calls in test classes and requires one injected
[TempDir] public required property. Both cited test methods add manual TempDir construction.

Rule 2808173: Use injected [TempDir] public required property in test classes instead of manual fields
test/Capacitor.Cli.Tests.Unit/SessionStartMemory/WorkItemsNudgeTests.cs[96-105]
test/Capacitor.Cli.Tests.Unit/SessionStartMemory/WorkItemsNudgeTests.cs[108-116]

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

## Issue description
Two newly added test methods manually instantiate `TempDir`, contrary to the required framework-injection pattern for test classes.

## Fix Focus Areas
- test/Capacitor.Cli.Tests.Unit/SessionStartMemory/WorkItemsNudgeTests.cs[96-118]

## Recommended Fix
Add a `[TempDir] public required TempDir Tmp { get; init; }` property to the test class and use that injected directory in the new tests. Isolate each generated configuration path as needed and remove the new `using var tmp = new TempDir()` statements.

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


5. Two public plan types share one file 📘 Rule violation ⚙ Maintainability
Description
PlanEntitlements.cs declares both public top-level types PlanFeature and PlanEntitlements. The
constants holder is neither an allowed extension class, hierarchy implementation, nor registry
descriptor, and callers must look in a file named for the other type to find it.
Code

src/Capacitor.Cli.Core/PlanEntitlements.cs[R6-10]

+public static class PlanFeature {
+    public const string WorkItems = "work_items";
+    public const string Projects  = "projects";
+    public const string Analytics = "analytics";
+}
Relevance

● Weak

Recent reviews rejected the same one-public-type-per-file request for closely matching files.

PR-#875
PR-#873

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The rule requires one public primary type per matching source file unless an enumerated exception
applies. The branch file declares the independently reused public PlanFeature constants class
alongside the public PlanEntitlements class.

Rule 3162234: One primary type per file, with only narrow documented exceptions
src/Capacitor.Cli.Core/PlanEntitlements.cs[3-10]
src/Capacitor.Cli.Core/PlanEntitlements.cs[12-18]

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

## Issue description
The new source file contains two public top-level types, while the repository convention permits one primary type per file except for narrow patterns that do not apply here.

## Fix Focus Areas
- src/Capacitor.Cli.Core/PlanEntitlements.cs[3-18]

## Recommended Fix
Move `PlanFeature` into a new `PlanFeature.cs` file in the same namespace and leave `PlanEntitlements` in `PlanEntitlements.cs`. Preserve the existing public API and update no callers beyond what compilation requires.

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


Grey Divider

Context sources
✅ Compliance rules (platform): 64 rules
✅ Cross-repo context — repo relationships
  Explored: repo: kurrent-io/kcap-server (sha: 39b2e1c8)
Review mode: 🧠 Deep: This is a cross-cutting behavioral change spanning entitlement parsing, durable caching, HTTP handler registration across multiple lanes, and nine session-start integrations, with many independent failure modes and subtle fail-open/staleness semantics.

Grey Divider

Tip of the day
💡 Did you know, you can group findings by type and pick your Finding display, from Minimal to Full

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

The in-process write memo suppressed an identical rewrite unconditionally,
so a long-lived process — the daemon, an MCP server — that kept observing
the same denial never refreshed the record's timestamp. After seven days its
own dedupe aged out the entry it was confirming, and the nudge came back.

Bound the suppression by a refresh interval well under the staleness
horizon, so repeated identical answers keep the record fresh while the hot
path still touches disk at most once an hour per distinct value.
["seen_at"] = at,
};

var tempPath = $"{path}.tmp";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The cache is shared by independent kcap processes, but every writer for one server uses the same .tmp path. During concurrent hook responses, process A can write a denial, process B can overwrite that temp file with an allowance, and A can move the bytes from B into the cache while memoizing the denial; the move by B then fails and is swallowed. A will skip subsequent identical denying headers for up to RefreshAfter, leaving the cache allowing a Free tenant and re-enabling the nudge this change is meant to suppress. Please use a unique temp file per write and make deduplication account for changes to the shared file by other processes; a concurrent-writer regression case would pin this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 09064ec — both halves, thank you.

The shared temp path is gone: each write publishes through {path}.{pid}.{guid}.tmp, so a move can only ever place its own bytes and the loser of a race can no longer have its file published by the winner.

The memo hole was the more interesting half, and you're right that the unique temp name alone doesn't close it — the memo records what we wrote, which stops being what the file holds the moment a peer can overwrite it. Suppression now re-reads and skips only when the cache still carries our answer, so a correction can't be suppressed against a file that disagrees. That's a small read on the hot path in place of a write, which seemed the right trade versus dropping deduplication entirely and writing on every authenticated response in the daemon.

Two regression cases: APeerOverwritingTheFile_IsCorrectedRatherThanSuppressed (fails against the previous commit) and ConcurrentWriters_LeaveAWholeFileAndNoTempFiles.

Worth flagging that ServerVersionStore has the same shared-.tmp pattern this was modelled on. The consequence there is milder — a wrong version string in an update notice, not a re-enabled nudge — so I've left it alone rather than widen this PR, but it's the same race and I'm happy to follow up separately if you'd rather it went too.

@realtonyyoung realtonyyoung left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

One actionable concurrency issue in the shared plan-entitlement cache; see the inline comment. Static review only; I did not build or run tests.

Two halves of one hole, both reachable when hooks fire concurrently on a
machine.

Every writer for a server used the same temp path, so two processes wrote
the same file and whoever moved first published whichever copy happened to
land there last — possibly the other process's. The loser's move then failed
against a file already gone and was swallowed, and the publisher went on to
memo a value the cache did not hold. Each write now publishes through a temp
name unique to it, so a move can only ever place its own bytes.

The memo records what we wrote, which is not what the file holds once a peer
can overwrite it. Suppression now re-reads and only skips when the cache
still carries our answer — a small read on the hot path in place of a write.
Without it a process could suppress its own correction for up to an hour
while the cache allowed a Free tenant, re-enabling the nudge this exists to
suppress.

Covers both with regression cases: a peer overwriting the file is corrected
rather than suppressed, and concurrent writers leave a whole file and no
temp files behind.
@realtonyyoung

Copy link
Copy Markdown
Collaborator

NO FINDINGS — re-reviewed the concurrent cache-write fix at 09064ec. Unique temp files prevent a peer from publishing another writer’s bytes, and the memo now verifies the shared file before suppressing a correction. Static review only; no build or tests run.

@realtonyyoung realtonyyoung left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The concurrency fix addresses my earlier finding; no remaining actionable findings in the current diff. Static review only; no build or tests run.

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