diff --git a/AGENTS.md b/AGENTS.md index 81e435d..30dbed6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,8 +36,16 @@ uv run pytest -q Use narrower tests when the change is scoped and the full suite would be disproportionate. The seam-linter checks truth freshness, schema pinning, and generated Markdown -provenance markers. Its identity-resolution check is intentionally deferred until -the dialect census produces the alias map. +provenance markers. Its identity-resolution check is opt-in: + +```sh +uv run operator-os-seam-linter --identity-resolution --identity-since 2026-07-03T13:02:06Z --truth output/portfolio-truth-latest.json --json +``` + +Use `--identity-since` for regression-gate checks after producer fixes. It +filters timestamped local stores only: bridge-db activity, session-costs, and +notification-hub durable events. Untimestamped Notion snapshot rows are skipped +in since-window mode. ## Known Risks diff --git a/README.md b/README.md index 04a84d2..6ef375b 100644 --- a/README.md +++ b/README.md @@ -360,8 +360,17 @@ The Operator-OS seam-linter is the small conformance check for that truth layer. It verifies the latest truth artifact freshness, schema pin, and generated Markdown provenance markers. -Identity-resolution enforcement remains a named v0.1 extension point and should -not be enabled until the dialect census and alias map exist. +Identity-resolution enforcement is opt-in. Use it without a window for backlog +audits, and with `--identity-since` after producer fixes when you need a current +regression gate: + +```bash +uv run operator-os-seam-linter --identity-resolution --identity-since 2026-07-03T13:02:06Z --truth output/portfolio-truth-latest.json --json +``` + +The since window applies only to timestamped local stores: bridge-db activity, +session-costs, and notification-hub durable events. Untimestamped Notion +snapshot rows are skipped in since-window mode. Phase 104 added a second standalone workspace mode: `--portfolio-context-recovery`. That mode freezes the active/recent weak-context cohort from the live truth snapshot, writes dry-run recovery plan artifacts into `output/`, skips dirty or temporary repos automatically, and can apply bounded minimum-context upgrades plus repo-level catalog seeds before regenerating the truth snapshot and compatibility outputs. diff --git a/src/operator_os_seam_linter.py b/src/operator_os_seam_linter.py index da4b119..4a6824b 100644 --- a/src/operator_os_seam_linter.py +++ b/src/operator_os_seam_linter.py @@ -79,6 +79,7 @@ def lint_operator_os_seams( bridge_db_path: Path | None = None, notification_db_path: Path | None = None, notion_snapshot_path: Path | None = None, + identity_since: datetime | None = None, now: datetime | None = None, ) -> SeamLintResult: generated_at = _aware(now or datetime.now(UTC)) @@ -106,6 +107,7 @@ def lint_operator_os_seams( bridge_db_path=bridge_db_path, notification_db_path=notification_db_path, notion_snapshot_path=notion_snapshot_path, + identity_since=identity_since, ) ) findings.extend(_check_generated_markdown(markdown_paths)) @@ -166,6 +168,9 @@ def main(argv: list[str] | None = None) -> int: bridge_db_path=args.bridge_db if args.identity_resolution else None, notification_db_path=args.notification_db if args.identity_resolution else None, notion_snapshot_path=args.notion_snapshot if args.identity_resolution else None, + identity_since=_parse_datetime(args.identity_since) + if args.identity_since is not None + else None, ) payload = result.to_dict() if args.worklist_output: @@ -197,6 +202,14 @@ def _build_parser() -> argparse.ArgumentParser: parser.add_argument("--bridge-db", type=Path, default=DEFAULT_BRIDGE_DB_PATH) parser.add_argument("--notification-db", type=Path, default=DEFAULT_NOTIFICATION_DB_PATH) parser.add_argument("--notion-snapshot", type=Path, default=DEFAULT_NOTION_SNAPSHOT_PATH) + parser.add_argument( + "--identity-since", + help=( + "Only audit timestamped local-store identities emitted at or after " + "this ISO-8601 timestamp. Applies to bridge-db activity, " + "session-costs, and notification-hub durable events." + ), + ) parser.add_argument("--expected-schema-version", default=SCHEMA_VERSION) parser.add_argument("--max-staleness-hours", type=int, default=DEFAULT_MAX_STALENESS_HOURS) parser.add_argument("--json", action="store_true") @@ -330,6 +343,7 @@ def _check_identity_resolution( bridge_db_path: Path | None, notification_db_path: Path | None, notion_snapshot_path: Path | None, + identity_since: datetime | None, ) -> list[SeamLintFinding]: resolver = _build_identity_resolver(truth) findings: list[SeamLintFinding] = [] @@ -339,6 +353,7 @@ def _check_identity_resolution( bridge_db_path=bridge_db_path, notification_db_path=notification_db_path, notion_snapshot_path=notion_snapshot_path, + identity_since=identity_since, ): checked += 1 raw = identity["value"] @@ -458,21 +473,40 @@ def _read_emitted_identities( bridge_db_path: Path | None, notification_db_path: Path | None, notion_snapshot_path: Path | None, + identity_since: datetime | None, ) -> list[dict[str, str]]: identities: list[dict[str, str]] = [] - identities.extend(_read_bridge_identities(bridge_db_path)) - identities.extend(_read_notification_identities(notification_db_path)) - identities.extend(_read_session_cost_identities(bridge_db_path)) - identities.extend(_read_notion_title_identities(notion_snapshot_path)) + identities.extend( + _read_bridge_identities(bridge_db_path, identity_since=identity_since) + ) + identities.extend( + _read_notification_identities( + notification_db_path, + identity_since=identity_since, + ) + ) + identities.extend( + _read_session_cost_identities(bridge_db_path, identity_since=identity_since) + ) + if identity_since is None: + identities.extend(_read_notion_title_identities(notion_snapshot_path)) return identities -def _read_bridge_identities(path: Path | None) -> list[dict[str, str]]: +def _read_bridge_identities( + path: Path | None, *, identity_since: datetime | None +) -> list[dict[str, str]]: if path is None or not path.exists(): return [] + where = "" + params: tuple[Any, ...] = () + if identity_since is not None: + where = " WHERE datetime(timestamp) >= datetime(?)" + params = (_sqlite_datetime(identity_since),) rows = _sqlite_rows( path, - "SELECT DISTINCT project_name, canonical_key FROM activity_log", + "SELECT DISTINCT project_name, canonical_key FROM activity_log" + where, + params, ) identities: list[dict[str, str]] = [] for project_name, canonical_key in rows: @@ -497,12 +531,20 @@ def _read_bridge_identities(path: Path | None) -> list[dict[str, str]]: return identities -def _read_session_cost_identities(path: Path | None) -> list[dict[str, str]]: +def _read_session_cost_identities( + path: Path | None, *, identity_since: datetime | None +) -> list[dict[str, str]]: if path is None or not path.exists(): return [] + where = "" + params: tuple[Any, ...] = () + if identity_since is not None: + where = " WHERE datetime(started_at) >= datetime(?)" + params = (_sqlite_datetime(identity_since),) rows = _sqlite_rows( path, - "SELECT DISTINCT project_name FROM session_costs", + "SELECT DISTINCT project_name FROM session_costs" + where, + params, ) return [ { @@ -515,13 +557,24 @@ def _read_session_cost_identities(path: Path | None) -> list[dict[str, str]]: ] -def _read_notification_identities(path: Path | None) -> list[dict[str, str]]: +def _read_notification_identities( + path: Path | None, *, identity_since: datetime | None +) -> list[dict[str, str]]: if path is None or not path.exists(): return [] if _sqlite_table_exists(path, "notification_feed"): rows = _sqlite_rows(path, "SELECT DISTINCT project FROM notification_feed") elif _sqlite_table_exists(path, "durable_events"): - rows = _sqlite_rows(path, "SELECT DISTINCT project FROM durable_events") + where = "" + params: tuple[Any, ...] = () + if identity_since is not None: + where = " WHERE datetime(created_at) >= datetime(?)" + params = (_sqlite_datetime(identity_since),) + rows = _sqlite_rows( + path, + "SELECT DISTINCT project FROM durable_events" + where, + params, + ) else: rows = [] return [ @@ -555,11 +608,13 @@ def _read_notion_title_identities(path: Path | None) -> list[dict[str, str]]: ] -def _sqlite_rows(path: Path, query: str) -> list[tuple[Any, ...]]: +def _sqlite_rows( + path: Path, query: str, params: tuple[Any, ...] = () +) -> list[tuple[Any, ...]]: try: uri = f"file:{path}?mode=ro" with sqlite3.connect(uri, uri=True) as conn: - return list(conn.execute(query).fetchall()) + return list(conn.execute(query, params).fetchall()) except sqlite3.Error: return [] @@ -586,6 +641,10 @@ def _is_silent_unresolved_identity(value: object) -> bool: return not text or text.lower() == "none" or bool(HEX_FRAGMENT_RE.fullmatch(text)) +def _sqlite_datetime(value: datetime) -> str: + return _aware(value).isoformat() + + def _repo_name(repo_full_name: str) -> str: return repo_full_name.rsplit("/", 1)[-1] diff --git a/tests/test_operator_os_seam_linter.py b/tests/test_operator_os_seam_linter.py index c11a7ee..ddc6ae0 100644 --- a/tests/test_operator_os_seam_linter.py +++ b/tests/test_operator_os_seam_linter.py @@ -73,30 +73,44 @@ def _write_identity_truth(path: Path) -> None: def _write_bridge_db( path: Path, *, - activity_rows: list[tuple[str, str | None]] | None = None, + activity_rows: list[tuple[str, str | None] | tuple[str, str | None, str]] + | None = None, session_cost_names: list[str | None] | None = None, + session_cost_rows: list[tuple[str | None, str]] | None = None, ) -> None: with sqlite3.connect(path) as conn: conn.execute( - "CREATE TABLE activity_log (project_name TEXT NOT NULL, canonical_key TEXT)" - ) - conn.execute("CREATE TABLE session_costs (project_name TEXT)") - conn.executemany( - "INSERT INTO activity_log (project_name, canonical_key) VALUES (?, ?)", - activity_rows or [], + "CREATE TABLE activity_log (project_name TEXT NOT NULL, canonical_key TEXT, timestamp TEXT)" ) + conn.execute("CREATE TABLE session_costs (project_name TEXT, started_at TEXT)") + for row in activity_rows or []: + project_name, canonical_key, *rest = row + timestamp = rest[0] if rest else "2026-07-03T12:00:00+00:00" + conn.execute( + "INSERT INTO activity_log (project_name, canonical_key, timestamp) VALUES (?, ?, ?)", + (project_name, canonical_key, timestamp), + ) + cost_rows = session_cost_rows or [ + (name, "2026-07-03T12:00:00+00:00") + for name in (session_cost_names or []) + ] conn.executemany( - "INSERT INTO session_costs (project_name) VALUES (?)", - [(name,) for name in (session_cost_names or [])], + "INSERT INTO session_costs (project_name, started_at) VALUES (?, ?)", + cost_rows, ) -def _write_notification_db(path: Path, projects: list[str | None]) -> None: +def _write_notification_db( + path: Path, + projects: list[str | None], + *, + created_at: str = "2026-07-03T12:00:00+00:00", +) -> None: with sqlite3.connect(path) as conn: - conn.execute("CREATE TABLE durable_events (project TEXT)") + conn.execute("CREATE TABLE durable_events (project TEXT, created_at TEXT)") conn.executemany( - "INSERT INTO durable_events (project) VALUES (?)", - [(project,) for project in projects], + "INSERT INTO durable_events (project, created_at) VALUES (?, ?)", + [(project, created_at) for project in projects], ) @@ -294,6 +308,101 @@ def test_identity_resolution_bridge_canonical_key_disagreement_fails( assert result.findings[0].violation == "bridge canonical_key disagrees with alias map" +def test_identity_resolution_since_ignores_old_timestamped_rows(tmp_path: Path) -> None: + truth, markdown = _passing_paths(tmp_path) + _write_identity_truth(truth) + bridge_db = tmp_path / "bridge.db" + notification_db = tmp_path / "notification.sqlite3" + _write_bridge_db( + bridge_db, + activity_rows=[ + ("old-minted-bridge", None, "2026-07-03T11:59:00Z"), + ], + session_cost_rows=[ + ("085", "2026-07-03T11:59:00+00:00"), + ], + ) + _write_notification_db( + notification_db, + ["old-minted-notification"], + created_at="2026-07-03T11:59:00+00:00", + ) + + result = lint_operator_os_seams( + truth_path=truth, + markdown_paths=markdown, + bridge_db_path=bridge_db, + notification_db_path=notification_db, + identity_since=datetime(2026, 7, 3, 12, 0, tzinfo=UTC), + now=NOW, + ) + + assert result.passed + + +def test_identity_resolution_since_skips_untimestamped_notion_snapshot( + tmp_path: Path, +) -> None: + truth, markdown = _passing_paths(tmp_path) + _write_identity_truth(truth) + notion_snapshot = tmp_path / "notion.json" + _write_notion_snapshot(notion_snapshot, ["new-minted-non-windowable-row"]) + + result = lint_operator_os_seams( + truth_path=truth, + markdown_paths=markdown, + notion_snapshot_path=notion_snapshot, + identity_since=datetime(2026, 7, 3, 12, 0, tzinfo=UTC), + now=NOW, + ) + + assert result.passed + + +def test_identity_resolution_since_includes_new_timestamped_rows(tmp_path: Path) -> None: + truth, markdown = _passing_paths(tmp_path) + _write_identity_truth(truth) + bridge_db = tmp_path / "bridge.db" + notification_db = tmp_path / "notification.sqlite3" + _write_bridge_db( + bridge_db, + activity_rows=[ + ("old-minted-bridge", None, "2026-07-03T11:59:00Z"), + ("new-minted-bridge", None, "2026-07-03T12:00:00Z"), + ], + session_cost_rows=[ + ("085", "2026-07-03T11:59:00+00:00"), + ("08f", "2026-07-03T12:00:00+00:00"), + ], + ) + _write_notification_db( + notification_db, + ["new-minted-notification"], + created_at="2026-07-03T12:00:01+00:00", + ) + + result = lint_operator_os_seams( + truth_path=truth, + markdown_paths=markdown, + bridge_db_path=bridge_db, + notification_db_path=notification_db, + identity_since=datetime(2026, 7, 3, 12, 0, tzinfo=UTC), + now=NOW, + ) + + assert not result.passed + assert [finding.violation for finding in result.findings] == [ + "minted identity dialect", + "minted identity dialect", + "silent unresolved identity", + ] + details = "\n".join(finding.detail for finding in result.findings) + assert "new-minted-bridge" in details + assert "new-minted-notification" in details + assert "old-minted-bridge" not in details + assert "085" not in details + + def test_cli_identity_resolution_is_opt_in(tmp_path: Path) -> None: truth, markdown = _passing_paths(tmp_path) bridge_db = tmp_path / "bridge.db" @@ -333,6 +442,43 @@ def test_cli_identity_resolution_is_opt_in(tmp_path: Path) -> None: assert code == 1 +def test_cli_identity_since_filters_timestamped_identity_rows(tmp_path: Path) -> None: + truth, markdown = _passing_paths(tmp_path) + _write_identity_truth(truth) + bridge_db = tmp_path / "bridge.db" + notification_db = tmp_path / "notification.sqlite3" + notion_snapshot = tmp_path / "notion.json" + _write_bridge_db( + bridge_db, + session_cost_rows=[ + ("085", "2026-07-03T11:59:00Z"), + ], + ) + + code = main( + [ + "--truth", + str(truth), + "--markdown", + str(markdown[0]), + "--markdown", + str(markdown[1]), + "--identity-resolution", + "--identity-since", + "2026-07-03T12:00:00Z", + "--bridge-db", + str(bridge_db), + "--notification-db", + str(notification_db), + "--notion-snapshot", + str(notion_snapshot), + "--json", + ] + ) + + assert code == 0 + + def test_cli_exits_nonzero_and_writes_worklist_on_failure(tmp_path: Path) -> None: truth, markdown = _passing_paths(tmp_path) _write_truth(truth, schema_version="0.6.0")