D170 — Selection before the merge: each provider is projected only its bound KBs¶
Decision. The client keeps the per-KB sync_pull responses unmerged
(candidateSet), and builds one manifest per provider: its bound KBs' responses
→ SelectForSources → MergeArtifactsStrict → VerifiedManifest →
FilterForProvider → Apply. FilterForProvider recomputes the revision,
ManagedFile records the artifact's Source, MCP entries are emitted per
provider, and sync gains a repeatable --client.
Rationale.
- The filter had to move before the merge. Filtering the merged manifest by
source is wrong, and the failure is silent data loss rather than a visible
error:
MergeArtifactsdeduplicates onkind+nameand prefers akb:source overbundle, so ifkb-Aoverrides a bundled skill and a provider is bound only tokb-B, the merge keepskb-A's copy and the source filter then deletes it — the provider ends with nothing where it should have received the bundled copy. The server performs the same KB-over-bundle merge inside each single-KB pull, so the discarded candidate cannot be reconstructed client-side. That is whyfetchCandidatesreturns responses keyed by KB. - The revision had to be recomputed.
FilterForProviderused to inheritm.Revision. With per-provider sets that makesComputeDiff.InSynccompare two different manifests under one string, and — worse — changing a binding would not change the revision, so no drift would ever be detected. The lock now records the revision of the manifest actually applied, which also fixed a pre-existing incoherence for providers that support only a subset of kinds. - Unbinding needs no removal logic. An unbound KB's artifacts leave the
provider's manifest, so
ComputeDiffmarks themRemovedandPruneManageddeletes them. The prune only touches files listed in the lock, so user-owned files survive. This is why the projection was built as a filter rather than as a separate teardown path. - Entry shape and entry set are different questions. A bare
/mcpauto-routes only when the server mounts exactly one KB (MultiKBServer.Handler); with four mounted and one bound, the client still needs?kb=.entriesForKBstherefore takes both lists. Symmetrically,removeMCPEntrieskeeps receiving the union of every known KB: it derives the names this client may own from the list it is given, so a filtered list would orphan an unbound KB's entry permanently. Both are pinned by tests. - The default resolves against live discovery, not the cache. A provider
with no binding receives every KB the server currently mounts, read from
/healthrather than fromknown_kbs—connectruns before that cache is refreshed, and a dry run never refreshes it, so using the cache produced an empty entry set on both paths. - A nameless endpoint disables bindings rather than starving the client. When the server does not identify its KBs by name, no binding can match. Every provider then receives everything, with a warning, exactly as before this change — a missing signal is not a reason to strip a client. The one exception is a provider explicitly bound to no KBs, where the declaration is unambiguous.
--clientleaves the others completely untouched, including their MCP entries and their lockfile entry, so a targeted sync cannot half-migrate a provider nobody asked about.ManagedFile.Sourceisomitemptyand "empty means unknown". Every lockfile written before this change has none; treating that as "wrong provenance" would makedoctorflag every existing machine on upgrade, andComputeDiffstill comparesContentHashonly, so nothing re-materializes.
Consequences. Every client's revision changes once, on the first sync after
release, because it is now computed after filtering — that is a recomputation,
not drift, and it must be called out in the release notes. Behaviour is otherwise
unchanged for anyone who declares no binding. statusManifestFn became
statusManifestsFn (per provider); materializeForProviders takes a
per-provider manifest map, with uniformManifests for the callers that
legitimately have one.