diff --git a/devtools/command_catalog.py b/devtools/command_catalog.py index 4dfd43d3ff..1841ad40cd 100644 --- a/devtools/command_catalog.py +++ b/devtools/command_catalog.py @@ -137,15 +137,6 @@ def to_dict(self) -> dict[str, object]: examples=("devtools verify", "devtools verify --quick", "devtools verify --all"), featured=True, ), - CommandSpec( - "schema-manifest", - "verification", - "Compare canonical SQLite schema manifests with archive tier files.", - "devtools.verify_schema_manifest", - json_flag=True, - use_when="Verify every tier's canonical create route and optionally compare it with an archive root.", - examples=("devtools schema-manifest", "devtools schema-manifest --archive-root /path/to/archive --json"), - ), CommandSpec( "gate", "verification", diff --git a/docs/devtools.md b/docs/devtools.md index 53c9fa4953..570b9fa468 100644 --- a/docs/devtools.md +++ b/docs/devtools.md @@ -70,7 +70,6 @@ These are the commands worth remembering during normal repo work: | --- | --- | | `devtools gate` | Run one named invariant check. | | `devtools scenario` | Run a named archive verification scenario. | -| `devtools schema-manifest` | Compare canonical SQLite schema manifests with archive tier files. | | `devtools smoke` | Probe deployed Polylogue binaries, daemon/web routes, and browser-capture archive flow. | | `devtools verify` | Run the local verification baseline: every quick gate, then the selected or complete test corpus. | diff --git a/polylogue/cli/commands/status.py b/polylogue/cli/commands/status.py index 0ee48253ee..af5d2897f8 100644 --- a/polylogue/cli/commands/status.py +++ b/polylogue/cli/commands/status.py @@ -244,7 +244,14 @@ def _status_operation_result( from polylogue.daemon.socket_path import daemon_socket_path from polylogue.version import POLYLOGUE_VERSION - config = load_effective_config(env) if daemon_url in (None, _BUILTIN_DAEMON_URL) else None + config: Any = None + if daemon_url in (None, _BUILTIN_DAEMON_URL): + try: + config = load_effective_config(env) + except Exception: + # Without a resolved config there is no socket to address, but a + # daemon on a discovered API port is still reachable over HTTP. + config = None client = ( DaemonClient( daemon_socket_path(config.archive_root), @@ -260,15 +267,22 @@ def _status_operation_result( request = OperationRequest("status", {"include_archive_readiness": include_archive_readiness}) def daemon_call(lowered: Any) -> Any: - if daemon_url is None or daemon_url == _BUILTIN_DAEMON_URL: - assert config is not None and client is not None - return client.operation( - lowered.operation, - dict(lowered.payload), - archive_root=str(config.archive_root), - daemon_version=POLYLOGUE_VERSION, - ) - for candidate in _candidate_daemon_urls(daemon_url): + if client is not None and config is not None: + try: + uds_value = client.operation( + lowered.operation, + dict(lowered.payload), + archive_root=str(config.archive_root), + daemon_version=POLYLOGUE_VERSION, + ) + except Exception: + uds_value = None + if uds_value is not None: + return uds_value + # No socket for this archive root does not mean no daemon: a + # dev-loop daemon on a discovered API port still answers over + # HTTP, which is what _candidate_daemon_urls is for. + for candidate in _candidate_daemon_urls(daemon_url or _BUILTIN_DAEMON_URL): try: request = Request(f"{candidate}/api/status", headers={"Accept": "application/json"}, method="GET") with urlopen(request, timeout=_FULL_TIMEOUT_S) as response: @@ -278,15 +292,10 @@ def daemon_call(lowered: Any) -> Any: continue return None - return OperationKernel( - daemon_call, - lambda _lowered: _show_direct_json( - env, - full=True, - include_archive_readiness=include_archive_readiness, - emit=False, - ), - ).execute(request) + # No direct fallback here: both callers render the direct surface + # themselves, so a fallback would assemble the whole direct payload once + # to be discarded. An unreachable daemon raises OperationKernelError. + return OperationKernel(daemon_call).execute(request) def _candidate_daemon_urls(primary_url: str) -> tuple[str, ...]: @@ -492,7 +501,7 @@ def _archive_one_tier_status(tier: str, path: Path) -> dict[str, Any]: return status try: - conn = open_readonly_connection(path, timeout_class="interactive-read") + conn = _open_status_connection(path) try: counts, precision = _archive_table_counts(conn, _ARCHIVE_TIER_TABLES[tier], db_size_bytes=probe.size_bytes) status["table_counts"] = counts @@ -583,6 +592,17 @@ def _sqlite_stat1_rows(conn: sqlite3.Connection) -> int: return _fast_count(conn, "SELECT COUNT(*) FROM sqlite_stat1") +def _open_status_connection(path: Path) -> sqlite3.Connection: + """Open a tier read-only for reporting. + + Status reports a skewed or unstamped tier as data. Validating the schema + on the way in raises the whole status surface out of service on exactly + the archive whose state the operator is asking about, so these reads + never validate. + """ + return open_readonly_connection(path, timeout_class="interactive-read", validate_schema=False) + + def _sqlite_maintenance_status(root: Path) -> dict[str, Any]: tiers: dict[str, dict[str, Any]] = {} total_wal_bytes = 0 @@ -598,7 +618,7 @@ def _sqlite_maintenance_status(root: Path) -> dict[str, Any]: total_wal_bytes += int(tier_status["wal_bytes"]) if path.exists(): try: - conn = open_readonly_connection(path, timeout_class="interactive-read") + conn = _open_status_connection(path) try: stat_rows = _sqlite_stat1_rows(conn) finally: @@ -678,7 +698,7 @@ def _archive_source_table_count(conn: Any, *, table: str, sql: str, configured_r if not source_db.exists(): return -1 try: - source_conn = open_readonly_connection(source_db, timeout_class="interactive-read") + source_conn = _open_status_connection(source_db) try: if not _table_exists(source_conn, table): return -1 @@ -698,7 +718,7 @@ def _archive_source_table_count(conn: Any, *, table: str, sql: str, configured_r if not source_db.exists(): return -1 try: - source_conn = open_readonly_connection(source_db, timeout_class="interactive-read") + source_conn = _open_status_connection(source_db) try: if not _table_exists(source_conn, table): return -1 @@ -730,7 +750,7 @@ def _ops_workload_status(active_root: Path, *, now_ms: int) -> dict[str, Any]: if not ops_db.exists(): return {"available": False, "reason": "missing_ops_tier"} try: - conn = open_readonly_connection(ops_db, timeout_class="interactive-read") + conn = _open_status_connection(ops_db) except sqlite3.Error as exc: return {"available": False, "reason": f"ops workload status unavailable: {exc}"} try: @@ -999,6 +1019,22 @@ def status_command( ) except Exception: operation_result = None + # An unparseable or timed-out /api/status is not a stopped daemon. When + # the named daemon still answers its liveness probe, say so instead of + # publishing local archive facts as daemon truth. + daemon_answered = operation_result is not None and operation_result.authority.get("mode") != "direct" + if ( + not daemon_answered + and daemon_url not in (None, _BUILTIN_DAEMON_URL) + and _daemon_live(daemon_url, timeout=_FAST_TIMEOUT_S) + ): + obs.attributes["daemon_reachable"] = True + obs.daemon_path = "daemon" + if output_format == "json": + _show_daemon_status_unavailable_json(env) + else: + _show_daemon_status_unavailable(env, compact=not full_payload) + raise click.exceptions.Exit(1) if operation_result is None: obs.attributes["daemon_reachable"] = False obs.daemon_path = "direct" @@ -1886,7 +1922,7 @@ def _direct_assertion_component(active_root: Path) -> dict[str, Any]: return component_from_assertion_substrate(table_exists=False).to_dict() try: - conn = open_readonly_connection(user_db, timeout_class="interactive-read") + conn = _open_status_connection(user_db) try: table_exists = _table_exists(conn, "assertions") if not table_exists: diff --git a/polylogue/daemon/fts_status.py b/polylogue/daemon/fts_status.py index b1b3cc1c90..0c8bc99412 100644 --- a/polylogue/daemon/fts_status.py +++ b/polylogue/daemon/fts_status.py @@ -259,8 +259,11 @@ def _archive_blocks_surface(conn: sqlite3.Connection) -> dict[str, int | bool | if source_exists and recorded_state == "ready" and source_rows == 0 and indexed_rows == 0 else False ) + # No ledger table at all is a legacy tier: readiness is structural. A + # present ledger that holds no row for this surface is an unmeasured + # surface, and stays not-ready. freshness_ready = ( - False + True if freshness_records is None else freshness_ready_record_trusted( state=recorded_state, @@ -277,11 +280,13 @@ def _archive_blocks_surface(conn: sqlite3.Connection) -> dict[str, int | bool | source_has_rows=source_has_rows, ) ) - # Ledger state is a cache of a measurement, not authority to refuse a - # read. Re-measure a non-trusted recorded scope against the rows the - # executor will actually search; a deferred single-session observation - # must not masquerade as archive-wide incompleteness. - if freshness is not None and not freshness_ready: + # A recorded state below `ready` is the ledger admitting it holds no + # current measurement: re-measure once against the rows the executor will + # actually search, so a deferred single-session observation cannot + # masquerade as archive-wide incompleteness. A recorded `ready` that fails + # the trust check already carries counts and a verdict — report it stale or + # unknown, because the request-safe path must not scan the source. + if freshness is not None and not freshness_ready and recorded_state != "ready": measured = _archive_exact_blocks_surface(conn) measured.update( { diff --git a/tests/infra/cli_interaction.py b/tests/infra/cli_interaction.py index d34bed54cc..417a81cf17 100644 --- a/tests/infra/cli_interaction.py +++ b/tests/infra/cli_interaction.py @@ -111,6 +111,7 @@ def _owners(values: Iterable[str], owner: str) -> dict[str, str]: "hooks": "tests/unit/cli/test_hooks.py", "import": "tests/unit/cli/test_import.py", "init": "tests/unit/cli/test_init.py", + "insights": "tests/unit/cli/test_insights.py", "judge": "tests/unit/cli/test_judge_command.py", "manual": "tests/unit/cli/test_manual_command.py", "mark": "tests/unit/cli/test_mark_note_identity.py", diff --git a/tests/infra/local_timezone.py b/tests/infra/local_timezone.py new file mode 100644 index 0000000000..bda4fa7d83 --- /dev/null +++ b/tests/infra/local_timezone.py @@ -0,0 +1,59 @@ +"""Pin a named local timezone for tests that render host-local times. + +`datetime.astimezone()` resolves the local zone through libc, which finds a +named zone only when a zoneinfo database is on disk at a location it knows. +This host ships no `/usr/share/zoneinfo`, so libc resolves names only via +`TZDIR`, and an environment that does not forward `TZDIR` silently degrades +`TZ=America/Los_Angeles` to a POSIX zero-offset zone named `America`. Deriving +`TZDIR` from `zoneinfo.TZPATH` keeps the pin independent of ambient state. +""" + +from __future__ import annotations + +import os +import time +import zoneinfo +from collections.abc import Iterator +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path + +import pytest + + +def _zoneinfo_directory(zone: str) -> str | None: + for candidate in zoneinfo.TZPATH: + if (Path(candidate) / zone).exists(): + return candidate + return None + + +@contextmanager +def pinned_local_timezone(monkeypatch: pytest.MonkeyPatch, zone: str) -> Iterator[None]: + """Make libc-local time render `zone`, restoring the prior zone after.""" + previous_tz = os.environ.get("TZ") + previous_tzdir = os.environ.get("TZDIR") + directory = _zoneinfo_directory(zone) + if directory is not None: + monkeypatch.setenv("TZDIR", directory) + monkeypatch.setenv("TZ", zone) + time.tzset() + probe = datetime(2026, 7, 1, 12, 0, tzinfo=timezone.utc) + expected = probe.astimezone(zoneinfo.ZoneInfo(zone)).utcoffset() + if probe.astimezone().utcoffset() != expected: + raise AssertionError( + f"could not pin local timezone to {zone!r}: libc reported {time.tzname!r}; " + "no zoneinfo database was reachable" + ) + try: + yield + finally: + for key, value in (("TZ", previous_tz), ("TZDIR", previous_tzdir)): + if value is None: + monkeypatch.delenv(key, raising=False) + else: + monkeypatch.setenv(key, value) + time.tzset() + + +__all__ = ["pinned_local_timezone"] diff --git a/tests/infra/test_local_timezone.py b/tests/infra/test_local_timezone.py new file mode 100644 index 0000000000..0d8be2bc76 --- /dev/null +++ b/tests/infra/test_local_timezone.py @@ -0,0 +1,34 @@ +"""Anti-vacuity: without this helper the pin silently degrades. + +Deleting the `TZDIR` derivation in `pinned_local_timezone` turns +`TZ=America/Los_Angeles` into a zero-offset POSIX zone on a host with no +`/usr/share/zoneinfo`, and `test_pin_holds_without_an_ambient_zoneinfo_directory` +goes red. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +from tests.infra.local_timezone import pinned_local_timezone + + +def _rendered(monkeypatch: pytest.MonkeyPatch) -> str: + with pinned_local_timezone(monkeypatch, "America/Los_Angeles"): + return datetime(2026, 7, 1, 19, 48, tzinfo=timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M %Z") + + +def test_pin_holds_without_an_ambient_zoneinfo_directory(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("TZDIR", raising=False) + + assert _rendered(monkeypatch) == "2026-07-01 12:48 PDT" + + +def test_pin_restores_the_previous_zone(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("TZ", "UTC") + + _rendered(monkeypatch) + + assert datetime(2026, 7, 1, 19, 48, tzinfo=timezone.utc).astimezone().utcoffset() == timedelta(0) diff --git a/tests/unit/cli/test_insights.py b/tests/unit/cli/test_insights.py index 99b18c2cb5..8661f1b8f6 100644 --- a/tests/unit/cli/test_insights.py +++ b/tests/unit/cli/test_insights.py @@ -589,7 +589,10 @@ def test_insights_status_plain(cli_workspace: CliWorkspace) -> None: assert result.exit_code == 0 assert "Convergence: caught up" in result.output - assert "session_profiles: degraded" in result.output + # Readiness is convergence plus counts: one row per seeded session, and the + # expected denominator gated on the product table. A reader that stopped + # reporting either would print a bare name. + assert "session_profiles: rows=2 expected=2" in result.output def test_insights_hermes_health_json_reports_disabled_without_a_hermes_root( diff --git a/tests/unit/cli/test_insights_command_runtime.py b/tests/unit/cli/test_insights_command_runtime.py index c2c2e5385a..fb6528255a 100644 --- a/tests/unit/cli/test_insights_command_runtime.py +++ b/tests/unit/cli/test_insights_command_runtime.py @@ -194,7 +194,7 @@ def test_render_status_plain_and_export_plain_cover_optional_sections( output = capsys.readouterr().out assert "Convergence: debt in derived" in output assert "Scope: origin=codex-session since=2026-04-01 until=2026-04-30" in output - assert "session_profiles: partial rows=7 expected=10" in output + assert "session_profiles: rows=7 expected=10" in output assert "missing=1 stale=2 orphan=3 incompatible=4" in output assert "origins: codex-session=7" in output assert "versions: materializer_version={'4': 7}" in output diff --git a/tests/unit/cli/test_query_fmt.py b/tests/unit/cli/test_query_fmt.py index 40f16bf316..eb62c520b6 100644 --- a/tests/unit/cli/test_query_fmt.py +++ b/tests/unit/cli/test_query_fmt.py @@ -18,8 +18,6 @@ import csv import io import json -import os -import time from collections.abc import Sequence from dataclasses import dataclass from datetime import datetime, timezone @@ -52,6 +50,7 @@ from polylogue.rendering.formatting import _conv_to_dict, _yaml_safe, format_session from tests.infra.builders import make_conv as build_conv from tests.infra.builders import make_msg as build_msg +from tests.infra.local_timezone import pinned_local_timezone @dataclass(frozen=True) @@ -489,10 +488,7 @@ def test_format_summary_list_contract(self, output_format: str) -> None: def test_text_summary_list_localizes_dates_without_changing_json_rows( self, monkeypatch: pytest.MonkeyPatch ) -> None: - previous = os.environ.get("TZ") - monkeypatch.setenv("TZ", "America/Los_Angeles") - time.tzset() - try: + with pinned_local_timezone(monkeypatch, "America/Los_Angeles"): summary = SessionSummary( id=SessionId("conv-summary-local-date"), origin=Origin.CLAUDE_AI_EXPORT, @@ -502,21 +498,12 @@ def test_text_summary_list_localizes_dates_without_changing_json_rows( text = format_summary_list([summary], "text", None) payload = json.loads(format_summary_list([summary], "json", None)) - finally: - if previous is None: - monkeypatch.delenv("TZ", raising=False) - else: - monkeypatch.setenv("TZ", previous) - time.tzset() assert "2025-05-31" in text assert payload["items"][0]["date"] == "2025-06-01" def test_csv_dates_stay_canonical_when_text_is_localized(self, monkeypatch: pytest.MonkeyPatch) -> None: - previous = os.environ.get("TZ") - monkeypatch.setenv("TZ", "America/Los_Angeles") - time.tzset() - try: + with pinned_local_timezone(monkeypatch, "America/Los_Angeles"): timestamp = datetime(2025, 6, 1, 1, tzinfo=timezone.utc) summary = SessionSummary( id=SessionId("conv-csv-local-date"), @@ -535,12 +522,6 @@ def test_csv_dates_stay_canonical_when_text_is_localized(self, monkeypatch: pyte summary_csv = format_summary_list([summary], "csv", None) hit_csv = format_search_hit_list([hit], "csv", None) session_csv = _format_list([session], "csv", None) - finally: - if previous is None: - monkeypatch.delenv("TZ", raising=False) - else: - monkeypatch.setenv("TZ", previous) - time.tzset() assert next(csv.DictReader(io.StringIO(summary_csv)))["date"] == "2025-06-01" assert next(csv.DictReader(io.StringIO(hit_csv)))["date"] == "2025-06-01" diff --git a/tests/unit/cli/test_query_stats.py b/tests/unit/cli/test_query_stats.py index d4087890e7..0cd9ccddb9 100644 --- a/tests/unit/cli/test_query_stats.py +++ b/tests/unit/cli/test_query_stats.py @@ -1,8 +1,6 @@ from __future__ import annotations import json -import os -import time from collections.abc import Iterator from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock @@ -11,21 +9,13 @@ from polylogue.archive.stats import ArchiveStats from polylogue.cli.query_stats import output_stats_sql +from tests.infra.local_timezone import pinned_local_timezone @pytest.fixture def fixed_local_timezone(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: - previous = os.environ.get("TZ") - monkeypatch.setenv("TZ", "America/Los_Angeles") - time.tzset() - try: + with pinned_local_timezone(monkeypatch, "America/Los_Angeles"): yield - finally: - if previous is None: - monkeypatch.delenv("TZ", raising=False) - else: - monkeypatch.setenv("TZ", previous) - time.tzset() @pytest.mark.asyncio diff --git a/tests/unit/core/test_localtime.py b/tests/unit/core/test_localtime.py index 4623d116c8..70697d7ee7 100644 --- a/tests/unit/core/test_localtime.py +++ b/tests/unit/core/test_localtime.py @@ -1,28 +1,18 @@ from __future__ import annotations -import os -import time from collections.abc import Iterator from datetime import datetime, timezone import pytest from polylogue.core.localtime import format_local_datetime +from tests.infra.local_timezone import pinned_local_timezone @pytest.fixture def fixed_local_timezone(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: - previous = os.environ.get("TZ") - monkeypatch.setenv("TZ", "America/Los_Angeles") - time.tzset() - try: + with pinned_local_timezone(monkeypatch, "America/Los_Angeles"): yield - finally: - if previous is None: - monkeypatch.delenv("TZ", raising=False) - else: - monkeypatch.setenv("TZ", previous) - time.tzset() def test_format_local_datetime_converts_utc_and_marks_zone(fixed_local_timezone: None) -> None: diff --git a/tests/unit/daemon/test_daemon_status.py b/tests/unit/daemon/test_daemon_status.py index e6e68aae08..8507cf62fe 100644 --- a/tests/unit/daemon/test_daemon_status.py +++ b/tests/unit/daemon/test_daemon_status.py @@ -2433,7 +2433,7 @@ def test_fts_readiness_requires_recorded_freshness_when_available(tmp_path: Path with sqlite3.connect(db_path) as conn: conn.executescript( """ - CREATE TABLE blocks (text TEXT); + CREATE TABLE blocks (text TEXT, search_text TEXT NOT NULL DEFAULT ''); CREATE TABLE messages_fts (text TEXT); CREATE TRIGGER messages_fts_ai AFTER INSERT ON blocks BEGIN SELECT 1; END; CREATE TRIGGER messages_fts_ad AFTER DELETE ON blocks BEGIN SELECT 1; END; diff --git a/tests/unit/daemon/test_http_write_coordination.py b/tests/unit/daemon/test_http_write_coordination.py index 88e1105714..46ab69692c 100644 --- a/tests/unit/daemon/test_http_write_coordination.py +++ b/tests/unit/daemon/test_http_write_coordination.py @@ -133,7 +133,11 @@ def _delete_authority_daemon(monkeypatch: pytest.MonkeyPatch, archive_root: Path thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: - yield DaemonClient(socket_path, timeout_s=2.0, auth_token="delete-authority-token") + # These routes delete hundreds of sessions through a real daemon. The + # client budget bounds one request, not the test: at two seconds it + # measured how loaded the host was. A genuine hang is still caught by + # the suite-wide pytest timeout. + yield DaemonClient(socket_path, timeout_s=60.0, auth_token="delete-authority-token") finally: server.shutdown() server.server_close() diff --git a/tests/unit/devtools/test_deployment_browser_smoke_service.py b/tests/unit/devtools/test_deployment_browser_smoke_service.py index 84f1a4fd81..26882305ad 100644 --- a/tests/unit/devtools/test_deployment_browser_smoke_service.py +++ b/tests/unit/devtools/test_deployment_browser_smoke_service.py @@ -37,12 +37,12 @@ def test_declared_live_provider_proof_declares_no_port_lease() -> None: assert all(spec.module != "devtools.live_provider_proof_service" for spec in COMMAND_SPECS) -def test_sinnixd_parser_accepts_the_unleased_shared_chrome_operation() -> None: - """Cross-contract proof against the production Sinnixd descriptor parser.""" +def test_agentctl_parser_accepts_the_unleased_shared_chrome_operation() -> None: + """Cross-contract proof against the production descriptor parser.""" repository_root = Path(__file__).resolve().parents[3] sinnix_root = Path("/realm/project/sinnix") package_roots = ( - sinnix_root / "pkgs" / "sinnixd", + sinnix_root / "pkgs" / "agentctl", sinnix_root / "pkgs" / "sinnix-mcp", sinnix_root / "pkgs" / "sinnix-lib", ) @@ -51,7 +51,7 @@ def test_sinnixd_parser_accepts_the_unleased_shared_chrome_operation() -> None: import sys from pathlib import Path -from sinnixd.projects import load_project_adapter +from agentctl.projects import load_project_adapter adapter = load_project_adapter(Path(sys.argv[1])) proof = adapter.operation("deployment_browser_smoke") @@ -59,8 +59,8 @@ def test_sinnixd_parser_accepts_the_unleased_shared_chrome_operation() -> None: "project_id": adapter.project_id, "operation_count": len(adapter.operations), "proof": { - "command": proof.command, - "parameters": proof.parameters, + "command": list(proof.command), + "parameters": list(getattr(proof, "parameters", ())), }, "service_operations": sorted( operation.name for operation in adapter.operations if getattr(operation, "service", None) is not None @@ -83,8 +83,8 @@ def test_sinnixd_parser_accepts_the_unleased_shared_chrome_operation() -> None: assert parsed["proof"] == { "command": ["python", "-m", "devtools.deployment_browser_smoke_service", "--json"], "parameters": [], - } - # Sinnixd allocates no ports; every proof binds its own. + }, "the shared-Chrome proof takes no parameters" + # The runtime allocates no ports; every proof binds its own. assert parsed["service_operations"] == [] assert all(spec.module != "devtools.deployment_browser_smoke_service" for spec in COMMAND_SPECS)