|
| 1 | +# IHS-138 - Decision sign-off sheet |
| 2 | + |
| 3 | +One row per open decision from [`plan.md`](./plan.md) section 11. Each has a |
| 4 | +recommendation and the reasoning; mark **Accept** / **Override** and note any |
| 5 | +change. Nothing in Stage 1 (presence flags) depends on these - they gate Stage 2 |
| 6 | +onward. |
| 7 | + |
| 8 | +Already settled (recorded in the plan, listed here for completeness): |
| 9 | + |
| 10 | +- **Version:** SDK 1.23.0, semver minor, shipped as a bug fix with a required |
| 11 | + opt-out and a required behaviour-change note. |
| 12 | +- **Where the option lives:** `Config` default -> store default -> per-call |
| 13 | + `merge=` override. |
| 14 | + |
| 15 | +--- |
| 16 | + |
| 17 | +## D1 - Returned object vs stored object (C2) |
| 18 | + |
| 19 | +**Question.** After a re-fetch, the store keeps the merged canonical object. What |
| 20 | +does a query method (`get` / `all` / `filters`) return? |
| 21 | + |
| 22 | +- **(a) Return the freshly built per-query object** (diverges from the store copy). |
| 23 | +- **(b) Return the canonical merged store object.** |
| 24 | + |
| 25 | +**Recommendation: (a).** |
| 26 | + |
| 27 | +**Why.** |
| 28 | + |
| 29 | +- Minor-version safe: what `client.get()` returns is byte-for-byte what it returns |
| 30 | + today (exactly the fields this query asked for). Only `store.get()` gets richer. |
| 31 | + Option (b) changes every query's return value to carry data the caller did not |
| 32 | + request - much harder to call non-breaking. |
| 33 | +- It shrinks the D2 blast radius: the only "living" object is the internal store |
| 34 | + copy; returned objects are stable per-query snapshots. |
| 35 | +- Less code: (b) needs the query path to map incoming -> canonical and swap |
| 36 | + returned references. |
| 37 | +- Clean mental model: "the return value is this query's result; the store |
| 38 | + accumulates across queries." |
| 39 | + |
| 40 | +**Cost / what to verify.** `client.get(id) is client.store.get(id)` stops being |
| 41 | +true (it holds today). Value equality `==` still holds (id-based, `node.py:720`). |
| 42 | +Add a `save()` test: `save()` calls `store.set(self)`, so `self` merges into the |
| 43 | +canonical copy while the caller keeps `self` - confirm no field is revived that |
| 44 | +the save did not touch. |
| 45 | + |
| 46 | +**Decision:** ____________________ |
| 47 | + |
| 48 | +--- |
| 49 | + |
| 50 | +## D2 - In-place mutation / living objects (C3) |
| 51 | + |
| 52 | +**Question.** Merge mutates the existing stored object, so a `store.get()` |
| 53 | +reference can change under the caller when an unrelated query re-fetches that node. |
| 54 | +Is that the intended model? |
| 55 | + |
| 56 | +**Recommendation: accept it, documented, with local-edit protection.** |
| 57 | + |
| 58 | +**Why.** |
| 59 | + |
| 60 | +- It is the correct property for a cache: every store reference points at the one |
| 61 | + canonical object, which is always current. The alternative (merge into a new |
| 62 | + object, replace the entry) leaves previously handed-out `store.get()` references |
| 63 | + stale and diverging from the store - worse. |
| 64 | +- Under D1(a) the surprise is confined to code that deliberately holds a |
| 65 | + `store.get()` result across queries; plain query return values never mutate. |
| 66 | +- Protect unsaved local edits: merge must skip fields flagged |
| 67 | + `value_has_been_mutated` (`attribute.py:100`) / `_peer_has_been_mutated` |
| 68 | + (`related_node.py:74`), so re-querying never discards in-memory changes. |
| 69 | + |
| 70 | +**Cost.** New, documented behaviour. Covered by the section 6 "behaviour change |
| 71 | +must be clear" requirement. |
| 72 | + |
| 73 | +**Decision:** ____________________ |
| 74 | + |
| 75 | +--- |
| 76 | + |
| 77 | +## D3 - Default for the public `store.set()` (C5) |
| 78 | + |
| 79 | +**Question.** The query-population path defaults to merge (the fix). What should the |
| 80 | +public `store.set(node=...)` default to? |
| 81 | + |
| 82 | +- **(a) `merge=False` (replace) by default**, opt into merge. |
| 83 | +- **(b) `merge=True` (merge) by default**, same as queries. |
| 84 | + |
| 85 | +**Recommendation: (a) - public `set()` defaults to replace.** |
| 86 | + |
| 87 | +**Why.** |
| 88 | + |
| 89 | +- "set" reads as an imperative "make the store hold this," like `dict[k] = v`. |
| 90 | + Merge is the enrichment behaviour the *query* path needs, not what an explicit |
| 91 | + `set` implies. |
| 92 | +- Preserves the documented contract ("store this object", `store.mdx:147`). That |
| 93 | + example sets a fresh object under a custom key, usually with no prior entry, so |
| 94 | + replace and merge are identical there - no example breaks. |
| 95 | +- The IHS-138 bug lives in the query path, not in manual `set()`. Keep the default |
| 96 | + change where the bug is; leave the explicit call predictable. |
| 97 | +- A user who wants enrichment passes `merge=True`. |
| 98 | + |
| 99 | +**Note.** This means the default differs by entry point: queries follow |
| 100 | +`Config.store_merge` (default merge); `set()` defaults to replace regardless. That |
| 101 | +is intentional and should be stated in the `set()` docstring. |
| 102 | + |
| 103 | +**Decision:** ____________________ |
| 104 | + |
| 105 | +--- |
| 106 | + |
| 107 | +## D4 - Config option name and type |
| 108 | + |
| 109 | +**Question.** How is the global default expressed on `Config`? |
| 110 | + |
| 111 | +- **(a) Bool `store_merge` with a full `description`** (drafted in plan section 3). |
| 112 | +- **(b) A clearer bool name** (e.g. `merge_store_results`). |
| 113 | +- **(c) Enum `store_update_mode: StoreUpdateMode` (`MERGE` / `REPLACE`)**, matching |
| 114 | + existing `Config` enums (`InfrahubClientMode`, `RecorderType`). |
| 115 | + |
| 116 | +**Recommendation: (a) - bool `store_merge` carried by its description.** |
| 117 | + |
| 118 | +**Why.** |
| 119 | + |
| 120 | +- Type-consistency with the per-call `merge: bool` argument. An enum on `Config` |
| 121 | + plus a bool per call is a mismatch; keeping both bool is simplest to reason about. |
| 122 | +- The clarity the user asked for is delivered by the `description` (which states |
| 123 | + both behaviours and the default), per the section 6 hard requirement - so the |
| 124 | + terse name is acceptable. |
| 125 | +- Enum is the fallback if call-site self-documentation is valued over type |
| 126 | + consistency, and we are willing to accept the enum/bool split (or make the |
| 127 | + per-call arg accept the enum too). |
| 128 | + |
| 129 | +**Decision:** ____________________ |
| 130 | + |
| 131 | +--- |
| 132 | + |
| 133 | +## D5 - Internal presence-flag naming |
| 134 | + |
| 135 | +**Question.** Name for the new "present in this response" flag on `Attribute` and |
| 136 | +`RelatedNode`. |
| 137 | + |
| 138 | +**Recommendation: `is_fetched`, with a uniform accessor across all three field |
| 139 | +types.** |
| 140 | + |
| 141 | +**Why.** |
| 142 | + |
| 143 | +- `RelatedNode` already has `initialized` meaning "has a peer" |
| 144 | + (`related_node.py:189`) - a *different* concept - so the new flag there cannot be |
| 145 | + called `initialized` without a confusing collision. |
| 146 | +- `RelationshipManager.initialized` already means "was fetched." Expose |
| 147 | + `is_fetched` on it as a thin alias of `initialized`, add a real `is_fetched` |
| 148 | + flag to `Attribute` and `RelatedNode`, and the merge code can branch uniformly |
| 149 | + on `field.is_fetched` regardless of field type. |
| 150 | +- Internal only (not public API), so low stakes - but the uniform accessor removes |
| 151 | + per-type special-casing in the merge. |
| 152 | + |
| 153 | +**Decision:** ____________________ |
| 154 | + |
| 155 | +--- |
| 156 | + |
| 157 | +## D6 - Scope of `merge=False` (replace) |
| 158 | + |
| 159 | +**Question.** When a node is stored with `merge=False`, does it also drop that |
| 160 | +node's relationship peers from the store, or only replace the node's own entry? |
| 161 | + |
| 162 | +**Recommendation: only replace the node's own entry.** |
| 163 | + |
| 164 | +**Why.** |
| 165 | + |
| 166 | +- Peers are independent store entries that other nodes may reference; cascading |
| 167 | + eviction could break unrelated references. |
| 168 | +- Peers fetched in the same query go through their own `set()` calls and follow the |
| 169 | + same `merge` flag independently. No special cascade needed. |
| 170 | + |
| 171 | +**Decision:** ____________________ |
| 172 | + |
| 173 | +--- |
| 174 | + |
| 175 | +## Summary table |
| 176 | + |
| 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 | |
| 184 | +| 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. |
0 commit comments