Privatize non-Cursor replays that attach new child content - #384
Conversation
kcap import --private failed to privatize a session when the run attached brand-new nested-child (subagent) content to a session that was already fully imported. The session kept whatever visibility it had — public, if an earlier non-private import stamped an org default — while carrying content the --private run had just added. The end-of-run privatize pass unions importedSessionIds (membership keyed on the raw ImportOutcome being Loaded/Resumed) with privateScopeSessionIds, the outcome-independent tracker that exists precisely so privacy does not depend on that classification. The latter was gated to vendor == "cursor", so a non-Cursor replay that attached child content fell through both: Antigravity's AlreadyLoaded repair branch reports a hardcoded Skipped however much child content it attached, and Gemini's replay posts its child content before session-end, so a failing session-end degrades the outcome to Failed. The replayed session-start's default_visibility cannot cover either — it is a create-time hint, and an already-ingested session already exists. Scope the capture by a new IImportSource.AttachesChildContentOnReplay capability instead of a vendor-name check, so the source owning the child-import pass is what decides and a new source has to answer at compile time. True for Cursor, Antigravity and Gemini; false for Claude/Codex (chain-based, never routed), Copilot/Kiro/Pi (no child import), and OpenCode (its ImportSessionAsync early-returns Skipped for AlreadyLoaded before posting anything). Closes #381 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The routed phase has covered every non-chain source for a long time; the leftover Cursor-only labels are what made the privatize gate's Cursor scoping read as intentional. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR Summary by QodoPrivatize replays that can attach child content
AI Description
Diagram
High-Level Assessment
Files changed (15)
|
Code Review by Qodo
1.
|
Address Qodo findings 1 and 2: the vendor enumeration now lives only in ReplayChildContentCapabilityTests, which pins it, rather than being restated in the interface doc, and the privateScopeSessionIds comment keeps the privacy invariant while dropping the mechanism the capability name already conveys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The flipped Effect 4 assertion did not actually guard importedSessionIds membership. The privatize set is HashSet(importedSessionIds) unioned with privateScopeSessionIds, and the Antigravity root is now in the private scope, so keying membership off the resolved Loaded outcome unions the same single id and still emits exactly one PUT. ComputePerSourceFinalCounts does not expose it either: for routed vendors it takes the routedOutcomes early return and ignores its imported argument. Observing the rule needs a source that attaches child content while sitting outside the private scope, which no shipped source does. Add a probe source that reports Skipped + SentChildContent while declaring AttachesChildContentOnReplay=false, and assert both that the counting override fired (Loaded 1, the Loading line) and that no visibility PUT followed. Verified by mutation: keying membership off resolved fails the new test and leaves the Antigravity one green. Corrected that test's doc and comment to stop claiming a guarantee it cannot provide. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The membership guard only covered the non-TTY branch: the TTY and non-TTY Parallel.ForEachAsync bodies each carried their own copy of the privatize capture, the counting resolution and the membership decision, so rewiring only the TTY copy would have left the new test green. The two branches could drift independently. Extract RecordRoutedResultAsync, which performs every non-rendering effect and returns the resolved outcome plus the raw one (the switch's null-when-Skipped arm needs it). Both branches now differ only in how they draw, leaving exactly one routed membership site and one privatize capture. Re-verified by mutation against the shared routine. Also rename the Antigravity test, whose name still carried the stays_out_of_ private_set claim that commit 5bf09ea removed from its comments. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
routedLoaded/routedExcluded/routedErrored were still incremented inside each renderer's switch, so the claim that the branches differ only in how they draw was false and a TTY-only drift in the totals mapping stayed invisible to the non-TTY tests. They are non-rendering effects; fold them into RecordRoutedResultAsync alongside the rest, leaving both switches purely renderer-specific. Also narrow the privacy-scope justification, which overreached: a false capability says only that the AlreadyLoaded call posts nothing, not that the source never posts. Copilot/Kiro/Pi/OpenCode all post on New and Partial, and OpenCode imports descendants there — that is the separately scoped residual, so the comment no longer erases it. The interface contract now names ClassificationStatus.AlreadyLoaded instead of the looser already-ingested phrasing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The narrowed contract was still literally false for three of the capability-false sources: Copilot, Kiro and Pi have no AlreadyLoaded early-return and still replay session-start and session-end there, so those calls do POST. Only OpenCode returns before any POST. Since the flag is about attaching transcript/child content, both comments now say the AlreadyLoaded call posts no transcript content, and keep the per-source distinction rather than implying lifecycle repair is absent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Problem
kcap import --privatefails to privatize a session when the run attaches brand-new nested-child (subagent) content to a session that was already fully imported. The session keeps whatever visibility it had — public, if an earlier non-private import stamped an org default — while carrying content the--privaterun just added. Narrow, but a genuine privacy leak.Mechanism
The end-of-run privatize pass unions two sets:
importedSessionIds— membership keyed on the call's rawImportOutcomebeingLoaded/Resumed.privateScopeSessionIds— the outcome-independent tracker that exists precisely so privacy doesn't depend on that classification. It was gated tovendor == "cursor".A non-Cursor replay that attaches child content falls through both. The two vendors fail differently, which matters for the tests:
Skippedhowever much child content the repair attachedLoaded/ResumedarmResumedon success,Failedif session-end failsGemini's success path was already covered, so the obvious "Gemini + new child content" test passes before the fix and proves nothing. Its real gap is that
ImportSubagentsAsyncposts the child transcript before session-end, so a failing session-end degrades the outcome toFailedwith the child content already public.The replayed
session-start'sdefault_visibilitycan't rescue either case: it is a create-time hint, and by definition an already-ingested session already exists. The old comment claimed non-Cursor vendors were covered by that stamp — they aren't, and Copilot/Gemini/Kiro/Cursor never emit one at all.Fix
Scope the capture by a new
IImportSource.AttachesChildContentOnReplaycapability rather than a vendor-name check, so the source that owns the child-import pass decides and a newly-added source has to answer at compile time:AlreadyLoadedreplay)ImportSessionAsyncreturns before any POST forAlreadyLoaded)falseclaims only that theAlreadyLoadedcall posts no transcript content — not that the source never posts, and not that it can never add content on New/Partial.Cursor's behaviour is unchanged. Counting/Done-grid behaviour is unchanged —
importedSessionIdsstill keys off the raw outcome.Routed bookkeeping collapsed to one site
Surfaced by review: the TTY and non-TTY
Parallel.ForEachAsyncbodies each carried their own copy of the privatize capture, the counting resolution, the per-vendor tracker, the membership decision and the aggregate totals — so a regression in one branch was invisible to tests that exercise a single display mode. All of it now lives inRecordRoutedResultAsync; the two branches differ only in how they draw.Tests
RoutedReplayPrivatizeTests(new) — the per-vendor pin: AntigravityAlreadyLoaded+new-child on the success path, Antigravity and Gemini on the session-end-failed path, plus a non-private zero-PUT guard. All three privatize assertions fail onmainfor the right reason and pass here.RoutedPrivatizeMembershipTests(new) — pins that the counting override never feedsimportedSessionIds. Needs a source that attaches child content while sitting outside the private scope, which no shipped source does, so it uses a deliberately contradictory probe. Asserts both halves (the override fired →Loaded 1; membership didn't follow → zero PUTs) so it can't pass vacuously. Mutation-verified: keying membership offresolvedfails it.ReplayChildContentCapabilityTests(new) — pins the capability per source, with a reflection guard so a newIImportSourcemust be classified rather than silently defaulting to "not captured".AntigravitySkippedChildOverrideRoutedLoopTests— its Effect 4 asserted zero visibility PUTs, i.e. it pinned this leak as correct behaviour. Now asserts exactly one PUT, and its doc records that it can no longer observe membership (the private scope covers the root, so the union absorbs raw-vs-resolved — verified by mutation) and points at the test that can.Local: integration 149/149. macOS unit failures are the flake cluster CI's own matrix comment documents (which is why CI excludes macOS) — 56 on a clean
origin/mainbaseline worktree vs 42 here, empty regression set, none import-related.Out of scope (observed, not fixed)
A related but distinct leak: a
New/Partialimport of any vendor whose lifecycle POST fails after content persisted returnsFailedand is likewise excluded from the privatize set. Different shape from this issue's "attaches child content on a replay", and fixing it would widen the change to all seven routed sources — so it is deliberately left alone rather than folded in silently.Review
Codex review flow: clean after 5 rounds (findings on the vacuous membership assertion, the uncovered TTY branch, the duplicated totals, and two overstated comments — all fixed). Qodo: 2 verbosity findings fixed; 1 README finding answered inline (the Getting started section never mentions
--privateand defers flags to the section that was updated).Closes #381
Linear: AI-1397