@@ -43,7 +43,10 @@ Add a `save()` test: `save()` calls `store.set(self)`, so `self` merges into the
4343canonical copy while the caller keeps ` self ` - confirm no field is revived that
4444the save did not touch.
4545
46- ** Decision:** ___________________ _
46+ ** Decision: ACCEPTED (a).** Query methods return the per-query object; the store
47+ holds the merged canonical copy. Guiding rule agreed with the reporter: * "queries
48+ hand you what you asked for; the store remembers the union of everything it has
49+ seen."*
4750
4851---
4952
@@ -70,7 +73,8 @@ Is that the intended model?
7073** Cost.** New, documented behaviour. Covered by the section 6 "behaviour change
7174must be clear" requirement.
7275
73- ** Decision:** ___________________ _
76+ ** Decision: ACCEPTED.** The store keeps one always-current, merged object per node;
77+ ` store.get() ` references reflect later fetches. Unsaved local edits are protected.
7478
7579---
7680
@@ -100,7 +104,14 @@ public `store.set(node=...)` default to?
100104` Config.store_merge ` (default merge); ` set() ` defaults to replace regardless. That
101105is intentional and should be stated in the ` set() ` docstring.
102106
103- ** Decision:** ___________________ _
107+ ** Decision: OVERRIDE -> (b) merge by default, uniformly.** The recommendation above
108+ was not taken. Agreed model: * anything that lands in the store gets merged* , with no
109+ special-casing by entry point - ` store.set() ` merges just like the query path. This
110+ is the simpler, single-rule mental model. Consequence: ** replace is opt-in only** -
111+ the sole way to store a node verbatim / drop previously cached data is
112+ ` store.set(node, merge=False) ` or the ` store_merge=False ` config opt-out. The public
113+ ` store.set() ` docstring must state that it merges by default and how to force
114+ replace.
104115
105116---
106117
@@ -126,7 +137,8 @@ is intentional and should be stated in the `set()` docstring.
126137 consistency, and we are willing to accept the enum/bool split (or make the
127138 per-call arg accept the enum too).
128139
129- ** Decision:** ___________________ _
140+ ** Decision: ACCEPTED (a).** Bool ` store_merge ` with the full description. Revisit only
141+ if the description proves insufficient in review.
130142
131143---
132144
@@ -150,7 +162,8 @@ types.**
150162- Internal only (not public API), so low stakes - but the uniform accessor removes
151163 per-type special-casing in the merge.
152164
153- ** Decision:** ___________________ _
165+ ** Decision: ACCEPTED.** ` is_fetched ` on ` Attribute ` and ` RelatedNode ` ; uniform
166+ ` is_fetched ` accessor on ` RelationshipManager ` aliasing its existing ` initialized ` .
154167
155168---
156169
@@ -168,20 +181,131 @@ node's relationship peers from the store, or only replace the node's own entry?
168181- Peers fetched in the same query go through their own ` set() ` calls and follow the
169182 same ` merge ` flag independently. No special cascade needed.
170183
171- ** Decision:** ___________________ _
184+ ** Decision: ACCEPTED.** ` merge=False ` replaces the node's own entry only; peers are
185+ handled independently.
186+
187+ ---
188+
189+ ## D7 - Mutation-flag lifecycle (added during implementation, 2026-07-04)
190+
191+ ** Question.** D2 protects "unsaved local edits" via the mutation flags
192+ (` value_has_been_mutated ` , ` _peer_has_been_mutated ` , ` _has_update ` ). The code review
193+ showed those flags were sticky - never reset after a successful save - so a saved
194+ edit would block merge refreshes of that field forever, and the store would serve
195+ the stale saved value even after the server changed. What is the flag lifecycle?
196+
197+ ** Decision: reset on save, propagate on merge.**
198+
199+ - A successful ` create() ` /` update() ` /` save() ` resets all three flag types
200+ (` _reset_mutation_tracking() ` at the end of ` _process_mutation_result ` ): the
201+ persisted values count as server state from then on.
202+ - The merge propagates the markers from an unsaved incoming copy instead of
203+ clearing them, so an unsaved edit merged into the store (manual ` store.set ` )
204+ keeps its will-be-saved status and is still sent by the store copy's next save.
205+
206+ ** Why.** The two halves depend on each other: propagation is only safe because
207+ saving resets the flags (a freshly saved copy no longer reads as "pending"), and
208+ resetting is what makes "local edits win" mean * unsaved* edits, which is what D2
209+ intended. Consequence, listed in the changelog: mutation tracking now resets after
210+ a successful mutation, so a second ` update() ` no longer re-sends relationship edits
211+ that were already persisted.
212+
213+ ---
214+
215+ ## D8 - Same-peer identity fields gate on presence (refines the merge rule)
216+
217+ ** Question.** The plan said cardinality-one peer identity "always refreshes" when
218+ the relationship was fetched. A payload carrying only ` node { id } ` for the * same*
219+ peer would then null previously fetched ` hfid ` /` display_label ` /` typename ` /` kind `
220+ and drop the cached ` _peer ` - the silent-loss class this feature exists to prevent
221+ (reachable via ` from_graphql ` on custom payloads + ` store.set ` , not via
222+ SDK-generated queries).
223+
224+ ** Decision: split by peer change.** A changed peer (different id, including cleared
225+ to none) takes the full incoming identity, so moves and move-to-root behave as
226+ specified. The same peer only refreshes the descriptive identity fields the
227+ incoming payload actually carried - consistent with how attribute properties and
228+ edge properties already merge.
229+
230+ ---
231+
232+ ## D9 - Timestamp-coherent store (supersedes grill item 3's mechanism, 2026-07-04)
233+
234+ ** Question.** Grill item 3 decided historical (` at ` ) reads must not blend into the
235+ live cache. The first implementation made ` at ` queries skip the store by default,
236+ with an explicit ` populate_store=True ` opt-in that forced replace. That required
237+ widening ` populate_store ` from ` bool = True ` to ` bool | None = None ` (to detect an
238+ explicit ` True ` ), left ` at ` + ` prefetch_relationships ` without ` .peer ` resolution,
239+ and - because the opt-in forced replace - quietly revived the IHS-138 bug for
240+ fully-historical scripts. Was the signature change actually needed?
241+
242+ ** Decision: no - make the store timestamp-coherent instead.** The store holds one
243+ timestamp context per branch: live data, or one ` at ` instant, stamped by the first
244+ population. Same-context queries use the store normally, so a script running all
245+ its queries at one ` at ` gets full store functionality (merge, ` .peer ` , hfid
246+ lookups). A mismatching population (live vs historical, or two different instants)
247+ emits a warning and skips the store for that query; the query itself still returns
248+ its results.
249+
250+ ** Why.**
251+
252+ - The incoherence grill item 3 feared comes only from * mixing* timestamps; a
253+ consistent-` at ` session is exactly as coherent as a live one and deserves the
254+ same cache behaviour, including the merge semantics this whole feature adds.
255+ - ` populate_store: bool = True ` is restored - no public signature change, no ` None `
256+ sentinel in a boolean parameter.
257+ - Strictly safer than both alternatives considered: pre-1.23 silently overwrote
258+ live entries with historical data; skip-by-default silently degraded historical
259+ scripts. Warn-and-skip makes the mismatch visible without breaking mixed scripts
260+ (which are safer than they were on 1.22, not worse).
261+ - Mismatch policy is warn + skip rather than raise: raising would break scripts
262+ that mix ` at ` and live queries, which worked (subtly wrong) before, and this
263+ ships as a semver-minor bug fix.
264+ - Documented footgun: recompute a relative timestamp per call and every query
265+ after the first trips the warning - compute ` at ` once, or use one
266+ ` client.clone() ` per timestamp (the context is per client store, per branch).
172267
173268---
174269
175270## Summary table
176271
177- | ID | Decision | Recommendation |
178- | ----| ----------| ---------------- |
179- | D1 | Returned vs stored object | (a) return per-query object; store is canonical |
180- | D2 | In-place mutation model | Accept, documented, protect local edits |
181- | D3 | ` store.set() ` default | (a) replace by default; queries merge by default |
182- | D4 | Config option name/type | (a) bool ` store_merge ` + full description |
183- | D5 | Presence-flag name | ` is_fetched ` , uniform accessor on all three types |
272+ | ID | Decision | Outcome |
273+ | ----| ----------| ---------|
274+ | D1 | Returned vs stored object | Return per-query object; store holds the merged canonical copy |
275+ | D2 | In-place mutation model | Accept; one always-current merged object per node; protect local edits |
276+ | D3 | ` store.set() ` default | ** Merge by default, uniformly ** (override); replace is opt-in via ` merge=False ` |
277+ | D4 | Config option name/type | Bool ` store_merge ` + full description |
278+ | D5 | Presence-flag name | ` is_fetched ` , uniform accessor on all three field types |
184279| D6 | ` merge=False ` scope | Node entry only; peers handled independently |
185-
186- Once D1-D3 are settled, Stage 2 onward is unblocked. D4-D6 can be confirmed during
187- implementation without reblocking.
280+ | D7 | Mutation-flag lifecycle | Reset on successful save; merge propagates pending markers (2026-07-04) |
281+ | D8 | Same-peer identity fields | Presence-gated for the same peer; full refresh on peer change (2026-07-04) |
282+ | D9 | ` at ` and the store | Timestamp-coherent per branch; mismatching populations warn + skip (2026-07-04) |
283+
284+ ** Guiding rule:** queries hand you what you asked for; the store remembers the union
285+ of everything it has seen. Replace happens only when explicitly requested
286+ (` merge=False ` / ` store_merge=False ` ).
287+
288+ All decisions are settled and implemented (D1-D6 as planned; D7/D8/D9 added during
289+ the 2026-07-04 code-review hardening - see plan section 13 for the full list of
290+ implementation outcomes, including the performance constraints on ` store.set() ` ).
291+ The remaining external gate is the 1.23.0 pre-release run of the Ansible collection
292+ and ` infrahubctl ` integration suites.
293+
294+ ## Grill refinements (2026-07-02)
295+
296+ Stress-testing the plan (see plan section 12) added these, all accepted:
297+
298+ - ** Merge scope** also covers node-level scalars (` display_label ` , ` typename ` ) and
299+ merges attributes/properties field-by-field (not object-swap).
300+ - ** Kind change is a documented exception to D2:** if ` typename ` differs for the same
301+ uuid (a ` ConvertObjectType ` migration), the store entry is * replaced* wholesale, not
302+ mutated in place - merging across schemas is incoherent.
303+ - ** ` at ` (time-travel) queries skip store population by default** (behaviour change
304+ from today; must be in the migration note). Explicit ` populate_store=True ` + ` at `
305+ replaces rather than blends. * Superseded by D9:* the shipped mechanism is a
306+ timestamp-coherent store (one ` at ` context per branch, warn + skip on mismatch),
307+ which keeps the same goal - never blend timestamps - without the ` populate_store `
308+ signature change.
309+ - ** Release gate:** Ansible collection + ` infrahubctl ` integration suites run against
310+ the 1.23.0 pre-release; migration note enumerates the D1 identity change and the
311+ ` at ` change with before/after.
0 commit comments