Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions llp/0276-hidden-rows-stay-off-the-sync-gate.decision.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions llp/0281-deferred-874-residuals.decision.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
163 changes: 163 additions & 0 deletions llp/0289-sync-lane-asks-the-store-about-hidden-picks.decision.md
Original file line number Diff line number Diff line change
@@ -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 <name>
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)
20 changes: 14 additions & 6 deletions src/core/cli/wizard/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -535,22 +535,30 @@ 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,
...(opts.stdin ? { stdin: opts.stdin } : {}),
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 } : {}),
Expand Down
Loading
Loading