Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 21 additions & 12 deletions .github/workflows/test-schema-persistence.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,8 @@ name: Schema Persistence Tests
on:
workflow_dispatch:
push:
paths:
- 'ledger/schema.py'
- 'ledger/client.py'
- 'ledger/adapter.py'
- 'tests/test_schema_persistence.py'
- 'pyproject.toml'
pull_request:
branches: [main, dev]
paths:
- 'ledger/schema.py'
- 'ledger/client.py'
- 'ledger/adapter.py'
- 'tests/test_schema_persistence.py'
- 'pyproject.toml'

defaults:
run:
Expand All @@ -30,17 +18,38 @@ jobs:
steps:
- uses: actions/checkout@v4

# #122 — detect whether any schema-relevant paths changed.
# The job always runs (satisfying branch-protection required-checks)
# but skips the expensive install+test when no relevant files changed.
- uses: dorny/paths-filter@v3
id: changes
with:
filters: |
schema:
- 'ledger/schema.py'
- 'ledger/client.py'
- 'ledger/adapter.py'
- 'tests/test_schema_persistence.py'
- 'pyproject.toml'

- uses: actions/setup-python@v5
if: steps.changes.outputs.schema == 'true'
with:
python-version: '3.13'

- name: Install dependencies
if: steps.changes.outputs.schema == 'true'
run: pip install -e ".[test]"

- name: Run schema persistence tests
if: steps.changes.outputs.schema == 'true'
run: pytest tests/test_schema_persistence.py -v --tb=short
env:
# tests construct their own surrealkv:// paths via tmp_path;
# this fallback prevents any fixture from defaulting to a shared db
SURREAL_URL: 'memory://'
REPO_PATH: ${{ github.workspace }}

- name: Schema paths unchanged — skip
if: steps.changes.outputs.schema != 'true'
run: echo "No schema-relevant files changed; skipping tests."
31 changes: 31 additions & 0 deletions cli/_diagnose_gather.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,26 @@ async def _read_schema_version(adapter) -> int | None:
return await _read_schema_version_raw(adapter._client)


_ROW_PROBE_TABLES = ("ledger_sync", "source_cursor")


async def _probe_row_deserialization(client) -> list[str]:
"""Probe operational tables for SurrealDB row-level deserialization (#301).

The ``diagnose`` gather checks table counts (structural) but not whether
rows can actually be read. A version-mismatch in the embedded SurrealKV
format manifests as ``Invalid revision `N` for type Value`` — schema
looks fine but row-level SELECT fails. This probe catches that gap.
"""
warnings: list[str] = []
for table in _ROW_PROBE_TABLES:
try:
await client.query(f"SELECT * FROM {table} LIMIT 1")
except Exception as exc: # noqa: BLE001
warnings.append(f"{table}: {type(exc).__name__}: {exc}")
return warnings


async def _read_table_counts_raw(client) -> dict[str, int]:
counts: dict[str, int] = {}
for table in _CANONICAL_TABLES:
Expand Down Expand Up @@ -203,6 +223,14 @@ def _compute_suggestions(d_partial: dict[str, Any]) -> list[str]:
f"Ledger schema {rec_schema} < binary schema {exp_schema}; "
"run `bicameral-mcp` once to apply pending migrations."
)
row_warnings = d_partial.get("row_probe_warnings", [])
if row_warnings:
suggestions.append(
f"Row-level deserialization errors in {len(row_warnings)} table(s): "
+ "; ".join(row_warnings)
+ ". This usually indicates a SurrealDB SDK version mismatch. "
"Back up the ledger file and `bicameral-mcp reset` to reinitialise."
)
return suggestions


Expand Down Expand Up @@ -237,6 +265,7 @@ async def gather_diagnosis_raw(client, ledger_url: str) -> Diagnosis:
first, last, last_at_iso, drift_status, running = await _read_bicameral_meta_raw(client)
schema_recorded = await _read_schema_version_raw(client)
table_counts = await _read_table_counts_raw(client)
row_probe_warnings = await _probe_row_deserialization(client)
channel_label, audit_path = _resolve_audit_log_channel()
recent_events = _tail_recent_events(audit_path, _RECENT_EVENT_TAIL)

Expand All @@ -249,6 +278,7 @@ async def gather_diagnosis_raw(client, ledger_url: str) -> Diagnosis:
"ledger_size_bytes": size_bytes,
"schema_version_recorded": schema_recorded,
"schema_version_expected": SCHEMA_VERSION,
"row_probe_warnings": row_probe_warnings,
}
suggestions = _compute_suggestions(partial)

Expand All @@ -268,6 +298,7 @@ async def gather_diagnosis_raw(client, ledger_url: str) -> Diagnosis:
drift_status=drift_status,
audit_log_channel=channel_label,
table_counts=table_counts,
row_probe_warnings=row_probe_warnings,
recent_events=recent_events,
suggestions=suggestions,
)
Expand Down
14 changes: 14 additions & 0 deletions cli/diagnose.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"drift_status",
"audit_log_channel",
"table_counts",
"row_probe_warnings",
"recent_events",
"suggestions",
}
Expand All @@ -62,6 +63,7 @@
"locates",
"schema_meta",
"bicameral_meta",
"ledger_sync",
)


Expand All @@ -85,6 +87,7 @@ class Diagnosis:
drift_status: str
audit_log_channel: str
table_counts: dict[str, int]
row_probe_warnings: list[str]
recent_events: list[dict[str, Any]]
suggestions: list[str]

Expand Down Expand Up @@ -135,6 +138,15 @@ def _format_table_counts_section(d: Diagnosis) -> str:
return "\n".join(lines) + "\n"


def _format_row_probe_section(d: Diagnosis) -> str:
if not d.row_probe_warnings:
return "## Row-level probe\n\nAll operational tables readable.\n"
lines = ["## Row-level probe\n"]
for w in d.row_probe_warnings:
lines.append(f"- ⚠ {w}")
return "\n".join(lines) + "\n"


def _format_recent_events_section(d: Diagnosis) -> str:
header = f"## Recent events (warn|error, last {len(d.recent_events)})\n\n"
header += f"_Audit log channel: {d.audit_log_channel} (redact if path is sensitive)_\n\n"
Expand Down Expand Up @@ -178,6 +190,8 @@ def format_diagnosis(d: Diagnosis) -> str:
+ "\n"
+ _format_table_counts_section(d)
+ "\n"
+ _format_row_probe_section(d)
+ "\n"
+ _format_recent_events_section(d)
+ "\n"
+ _format_suggestions_section(d)
Expand Down
5 changes: 4 additions & 1 deletion consent.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,10 @@ def telemetry_allowed() -> bool:

def _should_notify() -> bool:
"""True iff the notice has not been emitted for the current policy version."""
if os.getenv("BICAMERAL_SKIP_CONSENT_NOTICE", "").strip() == "1":
# #232 — use unified truthy vocabulary (1/true/yes/on)
from context import _GUIDED_MODE_TRUTHY

if os.getenv("BICAMERAL_SKIP_CONSENT_NOTICE", "").strip().lower() in _GUIDED_MODE_TRUTHY:
return False
marker = read_consent()
if marker is None:
Expand Down
4 changes: 2 additions & 2 deletions handlers/ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ def _check_canary(payload: dict) -> None:
env-disable shortcuts the detector cost (does not even serialize the
payload).
"""
if os.getenv("BICAMERAL_INGEST_CANARY_DISABLE", "").strip() == "1":
if os.getenv("BICAMERAL_INGEST_CANARY_DISABLE", "").strip().lower() in _GUIDED_MODE_TRUTHY:
return
from handlers import canary_patterns

Expand Down Expand Up @@ -235,7 +235,7 @@ def _check_sensitive(payload: dict) -> None:
disables in v1). The env disable shortcuts the detector cost
(does not even serialize the payload).
"""
if os.getenv("BICAMERAL_INGEST_SECRET_DISABLE", "").strip() == "1":
if os.getenv("BICAMERAL_INGEST_SECRET_DISABLE", "").strip().lower() in _GUIDED_MODE_TRUTHY:
return
from handlers import sensitive_patterns

Expand Down
1 change: 1 addition & 0 deletions tests/test_diagnose_allowlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def test_diagnosis_is_frozen_dataclass():
drift_status="first-write",
audit_log_channel="stderr",
table_counts={},
row_probe_warnings=[],
recent_events=[],
suggestions=[],
)
Expand Down
Loading