@@ -29,6 +29,10 @@ class PortfolioTruthPublishResult:
2929 project_registry_path : Path | None = None
3030
3131
32+ class PortfolioTruthPublishError (RuntimeError ):
33+ """Raised when publishing would corrupt or misrepresent portfolio truth."""
34+
35+
3236_REPO_ROOT = Path (__file__ ).resolve ().parents [1 ]
3337_CONFIG_DIR = _REPO_ROOT / "config"
3438
@@ -100,6 +104,11 @@ def publish_portfolio_truth(
100104 snapshot_stamp = build_result .snapshot .generated_at .strftime ("%Y-%m-%dT%H%M%SZ" )
101105 snapshot_path = output_dir / f"portfolio-truth-{ snapshot_stamp } .json"
102106 latest_path = truth_latest_path (output_dir )
107+ _guard_against_notion_context_drop (
108+ build_result .snapshot .source_summary ,
109+ latest_path = latest_path ,
110+ include_notion = include_notion ,
111+ )
103112 latest_name = latest_path .name
104113 snapshot_json = json .dumps (build_result .snapshot .to_dict (), indent = 2 ) + "\n "
105114 project_registry_path = output_dir / "project-registry.json"
@@ -184,3 +193,56 @@ def _content_changed(path: Path, content: str) -> bool:
184193 if not path .exists ():
185194 return True
186195 return path .read_text () != content
196+
197+
198+ def _guard_against_notion_context_drop (
199+ source_summary : dict [str , object ],
200+ * ,
201+ latest_path : Path ,
202+ include_notion : bool ,
203+ ) -> None :
204+ """Avoid overwriting local truth when Notion bootstrap silently disappears."""
205+ if not include_notion or not _notion_project_context_configured ():
206+ return
207+ current_rows = _int_value (source_summary .get ("notion_context_rows" ))
208+ if current_rows != 0 :
209+ return
210+ previous_rows = _previous_notion_context_rows (latest_path )
211+ if previous_rows is None or previous_rows <= 0 :
212+ return
213+ raise PortfolioTruthPublishError (
214+ "Refusing to publish portfolio truth with 0 Notion context rows because "
215+ f"{ latest_path } currently has { previous_rows } . Load NOTION_TOKEN or run "
216+ "with an explicit no-Notion path before replacing local portfolio truth."
217+ )
218+
219+
220+ def _notion_project_context_configured () -> bool :
221+ path = _CONFIG_DIR / "notion-config.json"
222+ try :
223+ data = json .loads (path .read_text ())
224+ except (OSError , json .JSONDecodeError ):
225+ return False
226+ return bool (str (data .get ("projects_data_source_id" , "" )).strip ())
227+
228+
229+ def _previous_notion_context_rows (latest_path : Path ) -> int | None :
230+ try :
231+ data = json .loads (latest_path .read_text ())
232+ except (OSError , json .JSONDecodeError ):
233+ return None
234+ source_summary = data .get ("source_summary" , {})
235+ if not isinstance (source_summary , dict ):
236+ return None
237+ return _int_value (source_summary .get ("notion_context_rows" ))
238+
239+
240+ def _int_value (value : object ) -> int | None :
241+ if isinstance (value , bool ):
242+ return None
243+ if isinstance (value , int ):
244+ return value
245+ try :
246+ return int (str (value ))
247+ except (TypeError , ValueError ):
248+ return None
0 commit comments