﻿# A recomputed config delta clears an unchanged access rule list

- **Status**: Done
- **Kind**: Bug
- **Authors**: jp
- **Date**: 2026-09-08

On a continuing conversation, a `--cfg` that changes `access.env` for a tool
also clears that tool's `access.fs` rules.
An empty `fs` list means unrestricted (workspace-confined) access, so the tool
silently gains filesystem reach the user never granted, and the widening
persists for every later turn.

The same happens in reverse, and for any pair of sibling rule lists: whichever
one did *not* change is the one that gets cleared.

## Why

`get_config_delta_from_cli` (`crates/jp_cli/src/cmd/query.rs:1860`) diffs the
conversation's config against the invocation's.
For a rule list that did not change, `rule_delta`
(`crates/jp_config/src/conversation/tool/access.rs:141`) returns an empty append
delta — `MergeableVec::Vec([])`, meaning "nothing to add".

`PartialAccessConfig` does not override `delta_with_unsets`, so it takes the
default at `crates/jp_config/src/delta.rs:37` and reports no cleared paths.
With `unsets` empty, `ConversationStream::add_config_delta`
(`crates/jp_conversation/src/stream.rs:587`) takes its re-diff branch and calls
`config.to_partial().delta(*delta)` — diffing the *delta* as though it were a
snapshot.

On that second pass the empty list is no longer "nothing to add"; it reads as a
complete rule set of zero rules.
`[].starts_with(&[rule])` is false, so `rule_delta` falls through to
`Replace([])`, and the fold resolves the field to empty.

An empty `MergeableVec` cannot distinguish "no change" from "cleared", so the
two passes are entitled to opposite readings of the same value.
No tweak to `rule_delta` alone removes the ambiguity.

## Scope

Not introduced by the rule-ordering fix in #1126.
Substituting the previous `rule_delta` body, an unchanged list also returned
`Vec([])` (`prev.iter().all(|r| next.contains(r))` holds), and the second pass
also emitted `Replace([])`.

The trigger is wider than a reorder: any delta carrying an access block whose
sibling list is unchanged, including a plain `--cfg` append of one env rule.

Only fields that never report an unset are exposed.
A delta carrying at least one cleared path is stored verbatim, so plain lists
going through `delta_opt_vec_at` are already immune.
Access rule lists are reachable precisely because they report nothing.

## Fixes

Two candidates, both defensible.

The root fix is to stop re-diffing a delta that was already computed against the
right state.
`add_config_delta` has the shape of the answer — it stores a delta as given
when `unsets` is non-empty, for exactly this reason — but it infers "already
computed" from a side effect instead of being told.
Carrying that explicitly on `ApplyDelta` fixes the whole class, including field
types added later.

The narrower fix is to give `PartialAccessConfig` a `delta_with_unsets` that
reports the field's path whenever `rule_delta` falls back to `Replace`, threaded
down through the tool and conversation partials the way the other reporting
types already do.
That is the `unsets` mechanism used as designed: a field emitting `Replace` is
saying merging cannot reach the value.
It leaves the second pass in place for any future field that states a strategy
without reporting one.

The root fix is preferable: the map and list conversions in flight put more
strategy-carrying fields on this path.

## Verifying

The delta law suite in `crates/jp_config/src/delta_law_tests.rs` cannot catch
this — it runs one delta-and-merge cycle.
A regression needs the query's calculation and `add_config_delta` together: a
conversation holding `fs` rules, an invocation changing only `env`, and an
assertion that the resolved `fs` rules survive.

## Comments

-----

- **From**: jp
- **Date**: 2026-09-08T10:01:44Z

Fixed by making `ConversationStream::add_config_delta` record what it is given.

## Corrections to the report

**The report describes #1126's branch, not `main`.** `delta_law_tests.rs` and
the `starts_with` body of `rule_delta` only exist there.
The Scope section's both-versions check holds, so the conclusion stands either
way.

**A worse case sits next to the reported one.** Adding a rule drops the rules
already there, which needs no sibling list at all.
A conversation granting `access.fs = [src]` and an invocation granting `[src,
docs]` produce the append-shaped delta `Vec([docs])`; re-read as a complete rule
set that says the tool has exactly one rule, and the fold resolves `fs` to
`["docs"]`.
Confirmed by `appending_an_access_rule_keeps_the_rules_already_there`, which
fails with `["docs"]` against the old body.

**`conversation.labels` was exposed too**, through `labels_delta`: an unchanged
map re-diffs to every key dropped, so the whole map is replaced with an empty
one.
`conversation.attachments` was not — `attachments_delta` has no replace
fallback.

**Four callers passed a raw partial, not a delta** (`config/set.rs:58`,
`summarize.rs:67`, `inquiry.rs:286`, `query.rs:611`), so for them the *first*
pass was already the category error.
`jp config set` on a conversation with label rules cleared them with no `--cfg`
naming labels.

**`ConversationStream::extend` double-diffed as well** (`stream.rs:1608`),
corrupting stream reconstruction — which is how a summary request is built.
Confirmed by `extending_a_stream_reproduces_the_config_of_each_event`.

**Plain lists were immune for a different reason than stated.** Not because a
delta carrying unsets is stored verbatim, but because `Option<Vec<T>>` has a
`None` that means "no change" and is distinct from `Some(vec![])`.
That is the invariant the fix restores.

## What landed

Neither proposed candidate.
The narrow fix cannot work: for the four raw-partial callers,
`delta_with_unsets` is never on the path.
The root fix turned out not to need a flag on `ApplyDelta` either — once
nothing re-diffs, a sparse partial *is* a valid delta ("merge these values"), so
the two inputs stop needing to be told apart.
`add_config_delta` now drops an apply that carries nothing and appends
everything else.

`add_config_reset` no longer duplicates the append: it existed separately only
because `add_config_delta` resolved the stream config, and
`test_add_config_reset_appends_reset_then_nonempty_layers` fails against the old
body, which proves the reason is gone.

Two effects beyond the bug: the stored JSON gets *smaller* (the re-diff wrapped
lists in a `{value, strategy, discard_when_merged}` envelope that is now just
the list), and `extend` loses a full config resolution per event, which was
O(n²) folds over a stream.

## Behaviour change to be aware of

`jp config set` with a value the conversation already resolves to now records
the delta instead of dropping it, pinning it against later workspace changes.
Pinned by `set_in_conversation_records_a_value_already_in_effect`.

## Coverage

Six tests, each watched failing against the old body before being kept:
`appending_an_access_rule_keeps_the_rules_already_there`,
`changing_env_rules_keeps_the_fs_rules`,
`extending_a_stream_reproduces_the_config_of_each_event`,
`a_stored_delta_holds_only_the_fields_that_changed` (exact JSON),
`without_an_unset_a_dropped_argument_appends_instead`, and
`set_in_conversation_records_a_value_already_in_effect`.

`Config Delta` is now in `docs/architecture/ubiquitous-language.md`, with the
delta-versus-snapshot distinction this bug came from.

## Still open

The ambiguity itself survives: `delta_mergeable_vec` and `delta_mergeable_map`
still cannot tell "nothing to add" from "cleared".
Nothing re-diffs a delta any more, so it is unreachable rather than absent.
#1130 and #1131 put every list and map in the config behind those helpers, so
the follow-up — giving the partial collections a state for "no opinion"
distinct from "empty" — is worth a ticket of its own.

-----

- **From**: jp
- **Date**: 2026-09-08T10:11:36Z
- **Re**: #1

One more consequence, raised in review and worth recording: the old body never
converged.

A conversation granting `access.fs = [src]` against an invocation granting
`[src, docs]` stored `Replace([docs])`, so the conversation resolved to
`["docs"]`.
The next identical run then saw `[docs]` against the wanted `[src, docs]` and
produced `Vec([src])`, which re-diffed to `Replace([src])` and dropped `docs`.
The run after that swapped them back.

So every `jp` run on such a conversation appended a config delta and moved the
resolved rule set, indefinitely.
The `{value, strategy, replace}` envelope in the stored delta was what caused
this, not what prevented it.

`a_repeated_invocation_records_no_second_delta` covers it: the second
invocation's diff must be empty and the stream must still hold one delta.
It fails against the old body with a delta re-adding the dropped rule.

Also recorded: `set_in_conversation_twice_records_two_deltas`.
`jp config set` passes values rather than a diff, so a repeat writes a second
delta where the old body dropped it.
Bounded by explicit invocations, each event the size of the `--cfg` values.

A value-equality check in `set.rs` was considered and rejected: comparing a
sparse partial field-wise against a resolved snapshot is the operation whose
ambiguity caused this bug.
The version that would be correct asks whether the conversation already pins the
value, which needs a fold over its own deltas alone.

-----

- **From**: jp
- **Date**: 2026-09-08T10:23:58Z
- **Re**: #2

Regression fixed in the same branch: a `config set` naming a value already in
effect no longer records an event.

The check is not a field-wise comparison of the override against the resolved
config — that is the sparse-versus-snapshot comparison this whole ticket is
about, and it carries the same ambiguity for every collection field.

`config_pipeline::override_to_record` merges the override onto the current
config, resolves both sides, and compares the resolved values:

```rust
let base = current.to_partial();
let mut merged = base.clone();
merged.merge(&(), overrides.clone())?;

let (Ok(before), Ok(after)) = (build(base), build(merged)) else {
    return Ok(Some(overrides));
};

Ok((before != after).then_some(overrides))
```

Resolved configs rather than partials, because a partial also carries the merge
strategy each field arrived with: two partials holding identical values can
still compare unequal, and resolution normalizes that away.
Both sides go through the same transform, so the comparison does not rest on
`to_partial` round-tripping exactly.
An override whose merged result does not resolve is recorded rather than dropped
— its outcome cannot be compared, so it is not known to be a no-op.

The function names no field and knows no merge strategy; every per-field
decision stays with the `#[setting(merge = ...)]` on the field.

Wired into both persisted override call sites: `config/set.rs` and the
editor-provided config in `query.rs`.
The two throwaway-stream callers (`summarize.rs`, `inquiry.rs`) are left alone;
nothing they append is persisted.

Five tests on the helper, covering how a field merges against whether the
override changes it: replacement restated and changed, a strategy-carrying list
restated and extended, and an appending list restated.
That last one records, which is correct — appending without deduplicating is
not idempotent, so asking for the same element twice is a real change. #1130
gives most lists dedup-by-default and adds `ordered_vec_with_strategy` for the
ones where repetition is meaningful, which splits that case deliberately.

`set_in_conversation_skips_a_value_already_in_effect` and
`set_in_conversation_twice_records_one_delta` cover it end to end.
Both were watched failing first.
