diff --git a/llp/0276-hidden-rows-stay-off-the-sync-gate.decision.md b/llp/0276-hidden-rows-stay-off-the-sync-gate.decision.md index b56553c0..ea610ccb 100644 --- a/llp/0276-hidden-rows-stay-off-the-sync-gate.decision.md +++ b/llp/0276-hidden-rows-stay-off-the-sync-gate.decision.md @@ -90,6 +90,14 @@ filter ("hiding is a display filter, never a catalog deletion"). > The split below covers the branches where `candidates` is empty *and* the > locked list is. A fifth case - a visible org row standing beside a hidden > pick - narrows the fleet sentence to the rows the fleet owns. +> +> **Extended-by: [LLP 0289 §ask-the-store](./0289-sync-lane-asks-the-store-about-hidden-picks.decision.md#ask-the-store).** +> The hidden *picks* cross as ids rather than a count, so the third +> sentence below can be checked against the client-sync store the export +> seam reads: a hidden pick with a standing `local-only` entry does not +> ship, and the branch reads the fourth sentence instead. The hidden +> *locked* rows keep their count, which the paragraph on `lockedHidden` +> and `candidatesHidden` at the end of this section predates. `runWizardSyncScope` short-circuits when there are no candidates, printing *"Everything you picked is managed by your fleet and always syncs"* over the diff --git a/llp/0281-deferred-874-residuals.decision.md b/llp/0281-deferred-874-residuals.decision.md index a40b5511..df73d305 100644 --- a/llp/0281-deferred-874-residuals.decision.md +++ b/llp/0281-deferred-874-residuals.decision.md @@ -31,6 +31,17 @@ summary. ## The visible org row is not a licence to speak for the rest {#visible-org-row} +> **Extended-by: [LLP 0289 §ask-the-store](./0289-sync-lane-asks-the-store-about-hidden-picks.decision.md#ask-the-store).** +> The branch below carries two claims, and only one of them is the policy +> store's to answer. Whether the machine's own capture *ships* is: the +> field becomes `candidatesHiddenIds`, and the second line prints only when +> at least one hidden pick has no standing `local-only` entry - the same +> question its no-locked sibling asks, so the two branches agree about what +> leaves the machine. Whether the fleet *owns* that row is not: an opt-out +> entry does not make a carried pick the fleet's, so the narrowed fleet +> sentence still fires on such a row existing, exactly as settled here. The +> rows are still never named. + `runWizardSyncScope`'s no-candidates branch, with a visible locked row, prints *"Everything you picked is managed by your fleet and always syncs"* over the org rows. `candidatesHidden > 0` says a carried hidden row diff --git a/llp/0289-sync-lane-asks-the-store-about-hidden-picks.decision.md b/llp/0289-sync-lane-asks-the-store-about-hidden-picks.decision.md new file mode 100644 index 00000000..ff717540 --- /dev/null +++ b/llp/0289-sync-lane-asks-the-store-about-hidden-picks.decision.md @@ -0,0 +1,163 @@ +# LLP 0289: The sync lane asks the store about the rows it may not name + +**Type:** Decision +**Status:** Accepted +**Systems:** Onboarding, CLI, Config +**Author:** Brendan / Claude +**Date:** 2026-08-19 +**Related:** LLP 0276 (#no-candidates: the sentence this qualifies, and the counts it widens), LLP 0188 (#opt-out: the store the export seam reads; #locked: why an org row needs no such question; #never-silent: the rule the unqualified sentence broke), LLP 0202 (#carry-through: how a hidden row ends up among the picks), LLP 0192 (#fail-closed: the seam that does the withholding) + +> Extends [LLP 0276 §no-candidates](./0276-hidden-rows-stay-off-the-sync-gate.decision.md#no-candidates): +> the hidden *picks* cross into the sync lane as ids rather than a count, +> because the sentence they license is a claim about the export seam and +> only the policy store can settle it. The hidden *locked* rows keep their +> count. + +## The problem {#problem} + +LLP 0276 §no-candidates gave the empty-candidate branch four sentences and +picked between them on two counts, `lockedHidden` and `candidatesHidden`. +The third one reads: + +``` +You picked nothing to record, but capture already set up on this machine +still syncs to your server. +``` + +It fires on `candidatesHidden > 0`, and `candidatesHidden` counts the rows +the display filter took off the screen. That count says a hidden picked row +*exists*. The sentence claims the row *ships*, and those are different +facts. + +A hidden row is off every wizard screen (LLP 0202, LLP 0276) but it is not +off every surface. It is a picker source with an id, and +`hyp policy client raw-anthropic local-only` addresses it by that id. The +entry lands in the machine-local client-sync store, which is exactly what +the export seam reads: `buildSourceWithholdResolver` withholds +`optedOutClientSourceIds(entries)` minus the central-classified ids +(`src/core/runtime/source_withhold.js`). So a user who ran that command, +then re-ran `hyp init` and picked nothing visible, was told their capture +still syncs while the seam was already withholding all of it. + +The lane could not tell. `optedOutBefore` is computed over `candidateIds`, +the *visible* candidates (`sync_scope.js:80-81`), so a hidden id was +invisible to the only store read the lane performs. It printed the +qualified sentence unconditionally, on the screen LLP 0188 #never-silent +exists to make truthful. + +## Decision {#ask-the-store} + +**The hidden picks reach the sync lane as ids, and the lane spends them on +the store rather than on the screen.** `RunWizardSyncScopeOptions`' +`candidatesHidden` count becomes `candidatesHiddenIds`, and the branch +fires only when at least one of those ids has no standing `local-only` +entry: + +- **Some hidden pick still ships.** The LLP 0276 sentence stands verbatim. +- **Every hidden pick is already withheld.** Nothing visible was picked and + nothing leaves the machine, so the branch reads the fourth sentence, + *"You picked nothing to record, so nothing syncs to your server."* That + is now the true one. + +The check is "any hidden pick ships", never "every one does": one withheld +row beside one standing row is still capture leaving the machine. + +**Ids, not a richer count.** The alternative was to keep the count and have +the orchestrator (`src/core/cli/wizard/index.js`) pre-answer the question, +passing "how many hidden picks are standing". That makes the wizard a +second reader of a privacy store the lane already reads, with its own +failure mode: the lane degrades a corrupt store to a warning and a skipped +step, while a read in the orchestrator would throw out of the whole run. +One reader, one interpretation, one failure path. + +**Withholding the ids was never the point.** LLP 0276 called them counts so +the lane "can tell the truth about them without being able to name them", +and naming them is what stays forbidden: the lane prints no hidden row, +before or after this change, and the tests assert it. What the lane gains +is the ability to *ask about* a row it may not print. A privacy screen that +is denied the identity of the thing it is describing cannot check its own +claim, and the LLP 0188 #never-silent cost of a wrong claim is higher than +the LLP 0202 cost of the lane holding an id it never renders. + +**The locked list keeps its count.** `lockedHidden` needs no store +question, and giving it one would be wrong. An org row always syncs +(LLP 0188 #locked) and the export seam deliberately drops opt-out entries +for central-classified sources, so a standing entry for a hidden locked row +is inert. Its sentence is unconditional because the fact is. The asymmetry +between the two fields is the asymmetry between the two kinds of row. + +## Consequences {#consequences} + +- `src/core/cli/wizard/index.js` passes `candidatesHiddenIds` (the picked + descriptors the display filter dropped, by id) in place of + `candidatesHidden`; `lockedHidden` is unchanged. +- `runWizardSyncScope` computes the store's full opted-out set, not only + the slice covering the visible candidates, and reads the hidden picks + against it. The visible-candidate path is untouched: `optedOutBefore` + still governs what the gate and menu render and what the write keeps, + because those screens edit only what they show. +- The store answers *"does it ship"*, never *"whose row is it"*. On the + sibling branch with a visible org row (LLP 0281 #visible-org-row) it + therefore governs only the line that promises an export; that branch's + fleet sentence keeps narrowing on a hidden pick existing, withheld or + not. +- The store is still not written on this path, opted-out hidden row or not. + It stays `noQuestion`, so LLP 0191 #back-edges and the step counter are + unaffected. +- A machine with no store file reads as nothing opted out (LLP 0188 + #migration), so a fresh enrolled run reaches the same sentence it did + before this change. + +## Not done {#not-done} + +LLP 0281 §visible-org-row (in review on PR #925, not on `master` at the +time of writing) reuses this same sentence on a sibling branch: a visible +org row standing beside a hidden pick. It carries the same exposure for the +same reason. When it lands it consults the same `candidatesHiddenIds` +answer this doc mints, so the two branches keep agreeing rather than +disagreeing about one fact. Nothing else about LLP 0281 is affected. + +It has since landed (PR #925, `d1f13959`), and the merge did that for the +half this doc governs. That branch prints two lines, and they answer to +different authorities. The second, *"Capture already set up on this machine +also syncs to your server."*, is a claim about the export seam, so it reads +the same `hiddenCandidateSyncs` boolean its no-locked sibling reads and the +two branches agree about what leaves the machine. The first is a claim +about ownership, and the store has nothing to say about it: a hidden pick +the store withholds is still not the fleet's row, so the fleet sentence +stays narrowed whenever such a row exists - the count LLP 0281 settled on. +Gating the narrowing on the store would have handed the fleet back an +owner's claim over a source the user had explicitly marked local-only, +which is the one thing LLP 0281 §visible-org-row exists to prevent. Done, +not open. + +The lane's question is the store's answer, not the seam's. The seam +withholds `optedOutClientSourceIds(entries)` *minus* the central-classified +ids (`source_withhold.js`), so a standing entry for a source the org owns +is inert and the row ships anyway. What makes the equation safe above is +that a hidden pick is non-central by construction, `lockedSources` being +exactly the central set - and that holds only while the wizard's locked set +resolved. It does not always: `computeLockedSafe` swallows a layered +resolution failure and returns `[]` (LLP 0182), and a converge timeout +returns `{ lockedSources: [] }` (LLP 0129), both deliberately, because the +picker is not the org boundary. On such a run a hidden *central* row with a +stale opt-out - writable before the org adopted the source, and never +removed when it did - falls into `candidatesHiddenIds`, reads as withheld, +and the lane prints *"nothing syncs"* while the daemon exports it. Closing +it needs the lane to tell "no central rows" from "could not tell", which is +a signal the orchestrator does not send today and which no count or id list +in this doc supplies; inventing one here would settle a question about the +degraded path that belongs in its own request. The narrower corner is +accepted for now over the unconditional wrong claim this doc removes: it +needs a resolution failure, a stale entry, and a hidden central row at +once, where the bug fixed here needed only `hyp policy client +local-only`. + +## References + +- LLP 0276, LLP 0202, LLP 0188, LLP 0192, LLP 0191 +- `src/core/cli/wizard/sync_scope.js` (`runWizardSyncScope`), + `src/core/cli/wizard/index.js` (the sync lane's call site), + `src/core/cli/wizard/types.d.ts` (`RunWizardSyncScopeOptions`), + `src/core/runtime/source_withhold.js` (the seam whose behaviour the + sentence claims), `src/core/usage-policy/client_sync.js` (the store) diff --git a/src/core/cli/wizard/index.js b/src/core/cli/wizard/index.js index 9461f09a..3077deaa 100644 --- a/src/core/cli/wizard/index.js +++ b/src/core/cli/wizard/index.js @@ -535,6 +535,7 @@ export async function runInitWizard(opts) { .filter((d) => d !== undefined) const lockedDescriptors = visiblePickerDescriptors(allLockedDescriptors) const candidateDescriptors = visiblePickerDescriptors(picked.descriptors) + const visibleCandidateIds = new Set(candidateDescriptors.map((d) => d.id)) const syncScope = await syncFn({ stdout: opts.stdout, stderr: opts.stderr, @@ -542,15 +543,22 @@ export async function runInitWizard(opts) { env: opts.env, candidates: candidateDescriptors, locked: lockedDescriptors, - // How many rows the display filter removed from each list. The - // lane never names them, but it must not tell the user nothing - // syncs while they stand: a locked row always syncs (LLP 0188 - // #locked), and a picked row carries no opt-out entry until the - // user writes one, so a hidden row dropped from either list is + // What the display filter removed from each list. The lane + // never names them, but it must not tell the user nothing syncs + // while they stand: a locked row always syncs (LLP 0188 + // #locked), and a picked row ships unless the policy store + // withholds it, so a hidden row dropped from either list may be // capture that still leaves the machine. + // A count suffices for the locked list, whose rows sync + // whatever the store says. The picked list does not: whether a + // hidden pick ships is the store's answer about that source, so + // the lane gets the ids and asks (it still never prints them). // @ref LLP 0276#no-candidates [implements]: the no-candidates line separates "no visible row to name" from "nothing standing at all" + // @ref LLP 0289#ask-the-store [implements]: the hidden picks cross as ids, the hidden locked rows as a count lockedHidden: allLockedDescriptors.length - lockedDescriptors.length, - candidatesHidden: picked.descriptors.length - candidateDescriptors.length, + candidatesHiddenIds: picked.descriptors + .filter((d) => !visibleCandidateIds.has(d.id)) + .map((d) => d.id), ...(syncProgress ? { progress: syncProgress } : {}), ...(opts.confirm ? { confirm: opts.confirm } : {}), ...(express ? { autoAccept: true } : {}), diff --git a/src/core/cli/wizard/sync_scope.js b/src/core/cli/wizard/sync_scope.js index 714146b0..b84e22b5 100644 --- a/src/core/cli/wizard/sync_scope.js +++ b/src/core/cli/wizard/sync_scope.js @@ -9,6 +9,7 @@ import { LOCKED_LABEL_SUFFIX } from './pick.js' import { ClientSyncListUnreadableError, clientSyncListPath, + optedOutClientSourceIds, readClientSyncEntries, writeClientSyncEntries, } from '../../usage-policy/index.js' @@ -42,8 +43,11 @@ const SYNC_SCOPE_MENU_TITLE = 'Choose what syncs. Unchecked sources stay on this * through that same display filter; when it is empty the step prints its * position plus the always-sync fact - or, with no org row to name * either, the nothing-picked fact, which only reads "nothing syncs" when - * `lockedHidden` and `candidatesHidden` both say no filtered-out row is - * standing - instead of prompting, so the counter never skips a number. + * `lockedHidden` and `candidatesHiddenIds` both say no filtered-out row is + * standing - instead of prompting, so the counter never skips a number. A + * hidden pick the store already withholds is not standing, which is why + * the picks arrive as ids and the locked rows as a count (LLP 0289 + * #ask-the-store). * * The write has editor semantics over the shown candidates only: entries * for sources not shown (a previously opted-out source the user unpicked @@ -79,6 +83,20 @@ export async function runWizardSyncScope(opts) { const candidateIds = new Set(opts.candidates.map((d) => d.id)) const optedOutBefore = new Set(existing.filter((e) => candidateIds.has(e.source)).map((e) => e.source)) + // The one question the lane may ask about a row it may not show: does a + // hidden pick still ship? `optedOutBefore` cannot answer it - it is + // computed over `candidateIds`, the *visible* candidates - and a hidden + // row is addressable in the store all the same + // (`hyp policy client raw-anthropic local-only`), so the ids go to the + // store and never to the screen. + // The store's answer, not the seam's: the seam also drops opt-out + // entries for central-classified sources, which this cannot see. That + // costs nothing while a hidden pick is non-central by construction, and + // the run where it is not is recorded as accepted in LLP 0289 #not-done. + // @ref LLP 0289#ask-the-store [implements]: the hidden picks reach the lane as ids so their sentence can be checked against the store the export seam reads + const optedOutAll = new Set(optedOutClientSourceIds(existing)) + const hiddenCandidates = opts.candidatesHiddenIds ?? [] + const hiddenCandidateSyncs = hiddenCandidates.some((id) => !optedOutAll.has(id)) if (opts.candidates.length === 0) { // Led by a blank line like every other block this lane prints, so the @@ -93,25 +111,30 @@ export async function runWizardSyncScope(opts) { // machine whose locked set is entirely hidden (LLP 0276 #sync-gate) - // the fleet's own capture still ships, so the line may not claim // nothing syncs; it just has no row to attribute it to. With no locked - // row but a hidden row among the picks - a carried raw source (LLP 0202 - // #carry-through) on a run whose org config has not converged - capture - // still ships and the fleet does not own it, so the line names neither - // the row nor an owner. Only with nothing standing at all is nothing - // picked and nothing synced. + // row but a hidden row among the picks that the store does not already + // withhold - a carried raw source (LLP 0202 #carry-through) on a run + // whose org config has not converged - capture still ships and the + // fleet does not own it, so the line names neither the row nor an + // owner. Only with nothing standing at all is nothing picked and + // nothing synced. The locked branch needs no such check: an org row + // always syncs (LLP 0188 #locked) and the export seam drops opt-out + // entries for central-classified sources, so a store entry for one is + // inert. // @ref LLP 0276#no-candidates [implements]: the no-candidates line states the fleet only when there is a visible org row to name, and never claims nothing syncs while a filtered-out row stands + // @ref LLP 0289#ask-the-store [implements]: a hidden pick the store withholds is not standing, so this branch reads "nothing syncs" instead of promising an export that will not happen if ((opts.locked ?? []).length === 0) { if ((opts.lockedHidden ?? 0) > 0) { opts.stdout.write( 'You picked nothing to record, but capture your fleet manages directly still syncs to your server.\n' ) - } else if ((opts.candidatesHidden ?? 0) > 0) { + } else if (hiddenCandidateSyncs) { opts.stdout.write( 'You picked nothing to record, but capture already set up on this machine still syncs to your server.\n' ) } else { opts.stdout.write('You picked nothing to record, so nothing syncs to your server.\n') } - return await finishSpan({ noQuestion: true, optedOut: [] }, opts) + return await finishSpan({ noQuestion: true, optedOut: [] }, opts, { hidden_picks_syncing: hiddenCandidateSyncs }) } // A hidden pick standing beside the org rows breaks the exhaustive // reading of the fleet sentence: the carried row (LLP 0202 @@ -119,20 +142,31 @@ export async function runWizardSyncScope(opts) { // syncs, so "everything you picked is managed by your fleet" hands the // fleet an owner's claim over capture it does not own. The org rows get // a sentence scoped to themselves, and the machine's own capture gets - // the line the no-locked branch already uses - a count, never a name. - // @ref LLP 0281#visible-org-row [implements]: a visible org row stops standing in for a hidden pick beside it - if ((opts.candidatesHidden ?? 0) > 0) { + // the line the no-locked branch already uses - a fact, never a name. + // Two claims, two questions. *Ownership* is not the store's to answer: + // a hidden pick the store withholds is still not the fleet's, so the + // fleet sentence narrows whenever such a row exists, which is the count + // LLP 0281 settled on. *Shipping* is the store's, so the second line - + // the one that promises an export - prints only when a hidden pick is + // not already withheld. That is the same question the no-locked branch + // asks, so the two agree about what leaves the machine without this one + // re-acquiring an owner's claim it gave up. + // @ref LLP 0281#visible-org-row [implements]: a visible org row stops standing in for a hidden pick beside it, withheld or not + // @ref LLP 0289#ask-the-store [implements]: the store answers whether the machine's own capture ships, not whether the fleet owns it + if (hiddenCandidates.length > 0) { opts.stdout.write('Your fleet manages these and they always sync:\n') for (const d of opts.locked ?? []) opts.stdout.write(` ${d.label}\n`) - opts.stdout.write('Capture already set up on this machine also syncs to your server.\n') - return await finishSpan({ noQuestion: true, optedOut: [] }, opts) + if (hiddenCandidateSyncs) { + opts.stdout.write('Capture already set up on this machine also syncs to your server.\n') + } + return await finishSpan({ noQuestion: true, optedOut: [] }, opts, { hidden_picks_syncing: hiddenCandidateSyncs }) } opts.stdout.write('Everything you picked is managed by your fleet and always syncs.\n') for (const d of opts.locked ?? []) opts.stdout.write(` ${d.label}\n`) // A statement, not a screen: `noQuestion` is what tells the lane after // this one that there is nothing here to step back *to* (LLP 0191 // #back-edges). - return await finishSpan({ noQuestion: true, optedOut: [] }, opts) + return await finishSpan({ noQuestion: true, optedOut: [] }, opts, { hidden_picks_syncing: hiddenCandidateSyncs }) } const ask = opts.prompt ?? defaultPromptFactory(opts) @@ -284,17 +318,29 @@ async function promptSyncScopeSelection({ opts, ask, confirm, optedOutBefore }) } /** + * The lane's one span. `hidden_picks` and `hidden_picks_syncing` carry the + * store answer the no-candidates sentence turns on (LLP 0289 + * #ask-the-store) so a later "it said nothing syncs but rows shipped" is + * triageable from the signal: the count separates "no hidden pick" from + * "hidden picks, all withheld", which print the same line. Counts and a + * boolean, never the ids - the lane holds them to ask the store, not to + * record them (LLP 0202). + * * @param {WizardSyncScopeResult} result * @param {RunWizardSyncScopeOptions} opts + * @param {{ hidden_picks_syncing?: boolean }} [extra] attributes only the + * caller knows, folded in when present * @returns {Promise} */ -async function finishSpan(result, opts) { +async function finishSpan(result, opts, extra) { await withSpan( 'wizard.sync_scope.finish', { [Attr.COMPONENT]: 'wizard', [Attr.OPERATION]: 'wizard.sync_scope.finish', candidates: opts.candidates.length, + hidden_picks: (opts.candidatesHiddenIds ?? []).length, + ...(extra ?? {}), sources_opted_out: result.optedOut.length, status: result.cancelled ? 'cancelled' : result.back ? 'backed' : result.skipped ? 'skipped' : 'ok', }, diff --git a/src/core/cli/wizard/types.d.ts b/src/core/cli/wizard/types.d.ts index b52858c3..58c12ee9 100644 --- a/src/core/cli/wizard/types.d.ts +++ b/src/core/cli/wizard/types.d.ts @@ -98,13 +98,21 @@ export interface RunWizardSyncScopeOptions { */ lockedHidden?: number /** - * How many picked rows the display filter removed from `candidates`, + * The picked rows the display filter removed from `candidates`, by id, * read for the same reason as `lockedHidden`: a carried hidden row * (LLP 0202 #carry-through) is composed into the config and syncs * unless an opt-out entry says otherwise, so the lane may not claim * nothing syncs while one stands - even though it may not name it. - */ - candidatesHidden?: number + * + * Ids rather than `lockedHidden`'s count (LLP 0289 #ask-the-store): + * "unless an opt-out entry says otherwise" is a question only the + * policy store can answer, and only about a named source. The lane puts + * them to the store and never to the screen. The locked list needs no + * such channel: an org row always syncs (LLP 0188 #locked) and the + * export seam drops opt-out entries for central-classified sources, so + * its count already decides its sentence. + */ + candidatesHiddenIds?: string[] /** The step's position line, rendered on the prompt like the pick lane's. */ progress?: string /** diff --git a/test/core/cli/wizard/index.test.js b/test/core/cli/wizard/index.test.js index 015175d8..07cc61d8 100644 --- a/test/core/cli/wizard/index.test.js +++ b/test/core/cli/wizard/index.test.js @@ -8,6 +8,8 @@ import path from 'node:path' import { firstLookHadRows, runInitWizard } from '../../../../src/core/cli/wizard/index.js' import { writeFirstSyncHoldMarker } from '../../../../src/core/usage-policy/first_sync_hold.js' +import { writeClientSyncEntries } from '../../../../src/core/usage-policy/client_sync.js' +import { runWizardSyncScope } from '../../../../src/core/cli/wizard/sync_scope.js' import { OVERVIEW_PROBE_SQL } from '../../../../src/core/query/overview.js' import { SUGGESTED_PROMPTS } from '../../../../src/core/cli/wizard/first_ask.js' @@ -483,7 +485,7 @@ test('runInitWizard: a hidden locked row stays off the sync-scope screen', async await runInitWizard(opts) assert.deepEqual(opts._syncOpts.locked, [claudeDescriptor], 'the hidden org row is filtered out, the visible one kept') assert.equal(opts._syncOpts.lockedHidden, 1, 'the lane is told a locked row was withheld, without being given the row') - assert.equal(opts._syncOpts.candidatesHidden, 0, 'nothing hidden among the picks') + assert.deepEqual(opts._syncOpts.candidatesHiddenIds, [], 'nothing hidden among the picks') }) // The candidate list takes the same filter. A carried hidden row (LLP 0202 @@ -505,7 +507,59 @@ test('runInitWizard: a hidden picked row is not a sync-scope candidate', async ( await runInitWizard(opts) assert.deepEqual(opts._syncOpts.candidates, [claudeDescriptor], 'the hidden row is not offered as an opt-out candidate') assert.equal(opts._syncOpts.lockedHidden, 0, 'nothing locked, so nothing was withheld from the locked list') - assert.equal(opts._syncOpts.candidatesHidden, 1, 'the lane is told a picked row was withheld, so it never claims nothing syncs') + assert.deepEqual( + opts._syncOpts.candidatesHiddenIds, + ['raw-anthropic'], + 'the lane is given the withheld pick by id, so it can ask the store whether it still syncs (LLP 0289 #ask-the-store)' + ) +}) + +// The sentence the empty-candidate branch prints when a hidden row was +// withheld from the screen is a claim about the export seam, and the export +// seam reads the client policy store (LLP 0188 #opt-out). A hidden picked +// row is addressable there ('hyp policy client raw-anthropic local-only'), +// so with a standing entry the row does not ship and the lane must not say +// it does. Driven through the real sync lane, so the assertion is on what +// the user reads rather than on the lane's inputs. +// @ref LLP 0289#ask-the-store [tests]: +test('runInitWizard: a hidden picked row with a standing opt-out does not make the lane claim capture syncs', async () => { + const home = await tmpHome() + const stateDir = path.join(home, '.hyp', 'hypaware') + await writeClientSyncEntries({ stateDir, entries: [{ source: 'raw-anthropic', class: 'local-only' }] }) + const catalog = emptyCatalog() + const rawDescriptor = { plugin: '@hypaware/ai-gateway', id: 'raw-anthropic', label: 'Anthropic API', hidden: true } + catalog.pickerDescriptors.set('raw-anthropic', rawDescriptor) + const { opts, stdout } = wizardOpts(home, { + fork: async () => 'team', + catalog, + pick: async () => pickResult({ descriptors: [rawDescriptor], sourcesPicked: ['raw-anthropic'] }), + syncScope: runWizardSyncScope, + }) + await runInitWizard(opts) + const text = stdout.text() + assert.match(text, /nothing syncs to your server/, 'the only standing pick is withheld by the store, so nothing ships') + assert.doesNotMatch(text, /still syncs to your server/) + assert.doesNotMatch(text, /raw-anthropic|Anthropic API/, 'the withheld row is still never named') +}) + +// The boundary of the same case: no entry in the store, so the carried +// hidden row does ship and the sentence LLP 0276 minted is the true one. +// @ref LLP 0289#ask-the-store [tests]: +test('runInitWizard: a hidden picked row with no opt-out keeps the sentence that says capture still syncs', async () => { + const home = await tmpHome() + const catalog = emptyCatalog() + const rawDescriptor = { plugin: '@hypaware/ai-gateway', id: 'raw-anthropic', label: 'Anthropic API', hidden: true } + catalog.pickerDescriptors.set('raw-anthropic', rawDescriptor) + const { opts, stdout } = wizardOpts(home, { + fork: async () => 'team', + catalog, + pick: async () => pickResult({ descriptors: [rawDescriptor], sourcesPicked: ['raw-anthropic'] }), + syncScope: runWizardSyncScope, + }) + await runInitWizard(opts) + const text = stdout.text() + assert.match(text, /still syncs to your server/) + assert.doesNotMatch(text, /nothing syncs to your server/) }) test('runInitWizard: a managed machine on the local pathway also runs the sync-scope step', async () => { diff --git a/test/core/cli/wizard/sync_scope.test.js b/test/core/cli/wizard/sync_scope.test.js index 88c11b27..c0891a6c 100644 --- a/test/core/cli/wizard/sync_scope.test.js +++ b/test/core/cli/wizard/sync_scope.test.js @@ -412,7 +412,7 @@ test('zero visible candidates with a hidden picked row: does not claim nothing s candidates: [], locked: [], lockedHidden: 0, - candidatesHidden: 1, + candidatesHiddenIds: ['raw-anthropic'], progress: 'Step 3 of 4 · Choose what syncs', prompt: async () => { prompted = true; return [] }, confirm: async () => { prompted = true; return 'accept' }, @@ -428,6 +428,90 @@ test('zero visible candidates with a hidden picked row: does not claim nothing s assert.equal(await readClientSyncEntries({ stateDir }), null, 'no store write on the no-question path') }) +// The same branch, asked of the store instead of assumed. A hidden picked +// row is addressable by 'hyp policy client raw-anthropic local-only', and +// the export seam reads exactly that store (LLP 0188 #opt-out), so with a +// standing entry the row does not ship: nothing was picked and nothing +// syncs. The sentence that says otherwise is a false promise on a +// privacy-facing screen (LLP 0188 #never-silent). +// @ref LLP 0289#ask-the-store [tests]: +test('zero visible candidates with a hidden picked row already opted out: says nothing syncs', async () => { + const { env, stateDir } = await makeHome() + await writeClientSyncEntries({ stateDir, entries: [{ source: 'raw-anthropic', class: 'local-only' }] }) + const stdout = makeBuf() + let prompted = false + + const result = await runWizardSyncScope(/** @type {any} */ ({ + stdout, stderr: makeBuf(), env, + candidates: [], + locked: [], + lockedHidden: 0, + candidatesHiddenIds: ['raw-anthropic'], + progress: 'Step 3 of 4 · Choose what syncs', + prompt: async () => { prompted = true; return [] }, + confirm: async () => { prompted = true; return 'accept' }, + })) + + assert.deepEqual(result, { noQuestion: true, optedOut: [] }) + assert.equal(prompted, false) + assert.match(stdout.text(), /nothing syncs to your server/) + assert.doesNotMatch(stdout.text(), /still syncs to your server/) + assert.doesNotMatch(stdout.text(), /raw-anthropic|Anthropic API/, 'the withheld row is never named, opted out or not') + assert.deepEqual( + await readClientSyncEntries({ stateDir }), + [{ source: 'raw-anthropic', class: 'local-only' }], + 'the no-question path still writes nothing' + ) +}) + +// One hidden pick withheld and one standing is still capture leaving the +// machine, so the qualified sentence stands: the check is "any hidden pick +// ships", never "every one does". +// @ref LLP 0289#ask-the-store [tests]: +test('zero visible candidates with one hidden pick opted out and one standing: does not claim nothing syncs', async () => { + const { env, stateDir } = await makeHome() + await writeClientSyncEntries({ stateDir, entries: [{ source: 'raw-anthropic', class: 'local-only' }] }) + const stdout = makeBuf() + + const result = await runWizardSyncScope(/** @type {any} */ ({ + stdout, stderr: makeBuf(), env, + candidates: [], + locked: [], + lockedHidden: 0, + candidatesHiddenIds: ['raw-anthropic', 'raw-openai'], + prompt: async () => [], + confirm: async () => 'accept', + })) + + assert.deepEqual(result, { noQuestion: true, optedOut: [] }) + assert.match(stdout.text(), /still syncs to your server/) + assert.doesNotMatch(stdout.text(), /nothing syncs to your server/) +}) + +// A locked row's sentence needs no store question: the export seam drops +// opt-out entries for central-classified sources (an org row always syncs, +// LLP 0188 #locked), so a stale entry for one is inert and the fleet line +// stays unconditional. +// @ref LLP 0289#ask-the-store [tests]: +test('a stale opt-out for a hidden locked row does not soften the fleet sentence', async () => { + const { env, stateDir } = await makeHome() + await writeClientSyncEntries({ stateDir, entries: [{ source: 'raw-anthropic', class: 'local-only' }] }) + const stdout = makeBuf() + + const result = await runWizardSyncScope(/** @type {any} */ ({ + stdout, stderr: makeBuf(), env, + candidates: [], + locked: [], + lockedHidden: 1, + candidatesHiddenIds: [], + prompt: async () => [], + confirm: async () => 'accept', + })) + + assert.deepEqual(result, { noQuestion: true, optedOut: [] }) + assert.match(stdout.text(), /capture your fleet manages directly still syncs to your server/) +}) + // The fifth no-question fact, and the residual LLP 0276 left open: a visible // org row and a hidden carried pick standing at the same time. The fleet row // is real, so the screen still names it - but the carried row composes into @@ -444,7 +528,7 @@ test('zero visible candidates with an org row and a hidden picked row: the fleet candidates: [], locked: [descriptor('claude')], lockedHidden: 0, - candidatesHidden: 1, + candidatesHiddenIds: ['raw-anthropic'], progress: 'Step 3 of 4 · Choose what syncs', prompt: async () => { prompted = true; return [] }, confirm: async () => { prompted = true; return 'accept' }, @@ -477,7 +561,7 @@ test('zero visible candidates with an org row and no hidden pick: keeps the exha candidates: [], locked: [descriptor('claude')], lockedHidden: 0, - candidatesHidden: 0, + candidatesHiddenIds: [], prompt: async () => [], confirm: async () => 'accept', })) @@ -486,6 +570,38 @@ test('zero visible candidates with an org row and no hidden pick: keeps the exha assert.doesNotMatch(stdout.text(), /also syncs to your server/) }) +// The two claims on this branch answer to different authorities. An opt-out +// entry settles whether the machine's own capture *ships*, so the second +// line goes; it does not make the withheld row the fleet's, so the fleet +// sentence stays narrowed to the rows the fleet owns (LLP 0281 +// #visible-org-row). The store is not a licence to re-acquire an owner's +// claim this branch gave up. +// @ref LLP 0289#ask-the-store [tests]: +test('zero visible candidates with an org row and a hidden pick already opted out: drops the sync line, keeps the narrowed fleet sentence', async () => { + const { env, stateDir } = await makeHome() + await writeClientSyncEntries({ stateDir, entries: [{ source: 'raw-anthropic', class: 'local-only' }] }) + const stdout = makeBuf() + + const result = await runWizardSyncScope(/** @type {any} */ ({ + stdout, stderr: makeBuf(), env, + candidates: [], + locked: [descriptor('claude')], + lockedHidden: 0, + candidatesHiddenIds: ['raw-anthropic'], + prompt: async () => [], + confirm: async () => 'accept', + })) + + assert.deepEqual(result, { noQuestion: true, optedOut: [] }) + // The store answered the shipping question, so the export promise goes. + assert.doesNotMatch(stdout.text(), /also syncs to your server/) + // It did not answer the ownership question, so this one may not come back. + assert.doesNotMatch(stdout.text(), /Everything you picked is managed by your fleet/) + assert.match(stdout.text(), /Your fleet manages these and they always sync:/) + assert.match(stdout.text(), /capture claude/) + assert.doesNotMatch(stdout.text(), /raw-anthropic|Anthropic API/, 'the withheld row is never named, opted out or not') +}) + test('a cancelled gate returns cancelled and writes nothing', async () => { const { env, stateDir } = await makeHome() const stderr = makeBuf()