Skip to content

Commit c3bc825

Browse files
authored
Merge branch 'main' into codex/public-fixture-proof-20260627
2 parents 0b8afd6 + d9b5a6b commit c3bc825

4 files changed

Lines changed: 375 additions & 2 deletions

File tree

src/cli.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,16 @@ def _build_report_subparser(subparsers: argparse._SubParsersAction) -> None: #
433433
p.add_argument(
434434
"--portfolio-truth", action="store_true", help="Generate canonical portfolio truth snapshot"
435435
)
436+
p.add_argument(
437+
"--portfolio-truth-allow-empty-notion",
438+
action="store_true",
439+
help=(
440+
"When live Notion context is unavailable, carry forward the previously "
441+
"published Notion context instead of refusing to publish. For headless or "
442+
"scheduled refreshes that update risk/activity signals without dropping "
443+
"advisory context to zero."
444+
),
445+
)
436446
p.add_argument(
437447
"--portfolio-context-recovery",
438448
action="store_true",
@@ -674,6 +684,16 @@ def build_parser() -> argparse.ArgumentParser:
674684
"risk factor (requires a prior `audit report --ghas-alerts` run)"
675685
),
676686
)
687+
parser.add_argument(
688+
"--portfolio-truth-allow-empty-notion",
689+
action="store_true",
690+
help=(
691+
"When live Notion context is unavailable, carry forward the previously "
692+
"published Notion context instead of refusing to publish. For headless or "
693+
"scheduled refreshes that update risk/activity signals without dropping "
694+
"advisory context to zero."
695+
),
696+
)
677697
parser.add_argument(
678698
"--portfolio-context-recovery",
679699
action="store_true",
@@ -5390,6 +5410,7 @@ def _run_portfolio_truth_mode(args) -> None:
53905410
catalog_path=Path(args.catalog) if args.catalog else None,
53915411
legacy_registry_path=legacy_registry_path,
53925412
include_notion=True,
5413+
allow_empty_notion=getattr(args, "portfolio_truth_allow_empty_notion", False),
53935414
release_count_by_name=release_count_by_name,
53945415
security_alerts_by_name=security_alerts_by_name,
53955416
)

src/portfolio_truth_publish.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
from dataclasses import dataclass
66
from pathlib import Path
77

8-
from src.portfolio_truth_reconcile import build_portfolio_truth_snapshot
8+
from src.portfolio_truth_reconcile import (
9+
build_portfolio_truth_snapshot,
10+
load_prior_notion_context,
11+
)
912
from src.portfolio_truth_render import render_portfolio_report_markdown, render_registry_markdown
1013
from src.portfolio_truth_types import truth_latest_path
1114
from src.portfolio_truth_validate import (
@@ -82,6 +85,7 @@ def publish_portfolio_truth(
8285
catalog_path: Path | None = None,
8386
legacy_registry_path: Path | None = None,
8487
include_notion: bool = True,
88+
allow_empty_notion: bool = False,
8589
release_count_by_name: dict[str, int] | None = None,
8690
security_alerts_by_name: dict[str, dict] | None = None,
8791
) -> PortfolioTruthPublishResult:
@@ -91,23 +95,28 @@ def publish_portfolio_truth(
9195
registry_output=registry_output,
9296
portfolio_report_output=portfolio_report_output,
9397
)
98+
latest_path = truth_latest_path(output_dir)
99+
notion_context_fallback = (
100+
load_prior_notion_context(latest_path) if allow_empty_notion else None
101+
)
94102
build_result = build_portfolio_truth_snapshot(
95103
workspace_root=workspace_root,
96104
catalog_path=catalog_path,
97105
legacy_registry_path=legacy_registry_path,
98106
include_notion=include_notion,
107+
notion_context_fallback=notion_context_fallback,
99108
release_count_by_name=release_count_by_name,
100109
security_alerts_by_name=security_alerts_by_name,
101110
)
102111
validate_truth_snapshot(build_result.snapshot)
103112

104113
snapshot_stamp = build_result.snapshot.generated_at.strftime("%Y-%m-%dT%H%M%SZ")
105114
snapshot_path = output_dir / f"portfolio-truth-{snapshot_stamp}.json"
106-
latest_path = truth_latest_path(output_dir)
107115
_guard_against_notion_context_drop(
108116
build_result.snapshot.source_summary,
109117
latest_path=latest_path,
110118
include_notion=include_notion,
119+
allow_empty_notion=allow_empty_notion,
111120
)
112121
latest_name = latest_path.name
113122
snapshot_json = json.dumps(build_result.snapshot.to_dict(), indent=2) + "\n"
@@ -200,8 +209,13 @@ def _guard_against_notion_context_drop(
200209
*,
201210
latest_path: Path,
202211
include_notion: bool,
212+
allow_empty_notion: bool = False,
203213
) -> None:
204214
"""Avoid overwriting local truth when Notion bootstrap silently disappears."""
215+
if allow_empty_notion:
216+
# Operator explicitly opted into publishing without live Notion (a headless or
217+
# scheduled refresh); prior advisory is carried forward where available.
218+
return
205219
if not include_notion or not _notion_project_context_configured():
206220
return
207221
current_rows = _int_value(source_summary.get("notion_context_rows"))

src/portfolio_truth_reconcile.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import json
34
import logging
45
from collections import Counter
56
from dataclasses import dataclass
@@ -180,6 +181,7 @@ def build_portfolio_truth_snapshot(
180181
catalog_path: Path | None = None,
181182
legacy_registry_path: Path | None = None,
182183
include_notion: bool = True,
184+
notion_context_fallback: dict[str, dict[str, str]] | None = None,
183185
now: datetime | None = None,
184186
release_count_by_name: dict[str, int] | None = None,
185187
security_alerts_by_name: dict[str, dict] | None = None,
@@ -188,6 +190,18 @@ def build_portfolio_truth_snapshot(
188190
catalog_data = load_portfolio_catalog(catalog_path)
189191
legacy_rows = load_legacy_registry_rows(legacy_registry_path)
190192
notion_context = load_safe_notion_project_context() if include_notion else {}
193+
notion_context_carried_forward = False
194+
if include_notion and not notion_context and notion_context_fallback:
195+
# Live Notion was unavailable; carry forward the prior published context so
196+
# a headless refresh updates risk/activity signals without dropping advisory
197+
# data to zero. The caller opts in via publish_portfolio_truth(allow_empty_notion=True).
198+
notion_context = notion_context_fallback
199+
notion_context_carried_forward = True
200+
logger.warning(
201+
"Live Notion context unavailable; carrying forward %d project rows "
202+
"from the prior portfolio-truth artifact.",
203+
len(notion_context),
204+
)
191205

192206
workspace_projects = discover_workspace_projects(
193207
workspace_root,
@@ -217,6 +231,7 @@ def build_portfolio_truth_snapshot(
217231
"catalog_warnings": list(catalog_data.get("warnings") or []),
218232
"legacy_registry_rows": len(legacy_rows),
219233
"notion_context_rows": len(notion_context),
234+
"notion_context_carried_forward": notion_context_carried_forward,
220235
"context_quality_counts": dict(
221236
Counter(project.derived.context_quality for project in projects)
222237
),
@@ -251,6 +266,46 @@ def build_portfolio_truth_snapshot(
251266
)
252267

253268

269+
def load_prior_notion_context(latest_path: Path) -> dict[str, dict[str, str]]:
270+
"""Reconstruct a Notion project-context map from a previously published
271+
portfolio-truth artifact, keyed identically to live Notion context
272+
(``_normalize(display_name)`` -> ``{portfolio_call, momentum, current_state}``).
273+
274+
Used to carry advisory context forward on a headless refresh when a live
275+
Notion token is unavailable, rather than overwriting local truth with zero
276+
rows. Only projects that actually carried Notion advisory are returned, so
277+
the resulting row count reflects real carried context. Returns an empty map
278+
when the artifact is missing or malformed.
279+
"""
280+
try:
281+
data = json.loads(latest_path.read_text())
282+
except (OSError, json.JSONDecodeError):
283+
return {}
284+
projects = data.get("projects")
285+
if not isinstance(projects, list):
286+
return {}
287+
context: dict[str, dict[str, str]] = {}
288+
for project in projects:
289+
if not isinstance(project, dict):
290+
continue
291+
identity = project.get("identity")
292+
advisory = project.get("advisory")
293+
if not isinstance(identity, dict) or not isinstance(advisory, dict):
294+
continue
295+
display_name = str(identity.get("display_name", "")).strip()
296+
portfolio_call = str(advisory.get("notion_portfolio_call", "")).strip()
297+
momentum = str(advisory.get("notion_momentum", "")).strip()
298+
current_state = str(advisory.get("notion_current_state", "")).strip()
299+
if not display_name or not (portfolio_call or momentum or current_state):
300+
continue
301+
context[_normalize(display_name)] = {
302+
"portfolio_call": portfolio_call,
303+
"momentum": momentum,
304+
"current_state": current_state,
305+
}
306+
return context
307+
308+
254309
def _duplicate_display_names(projects: list[PortfolioTruthProject]) -> list[str]:
255310
return sorted(
256311
name

0 commit comments

Comments
 (0)