diff --git a/contracts.py b/contracts.py index bc2814d..c6553d6 100644 --- a/contracts.py +++ b/contracts.py @@ -526,6 +526,9 @@ class IngestDecision(BaseModel): signoff: dict | None = None feature_group: str | None = None decision_level: str | None = None # L1 | L2 | L3 — #340 auto-classified when omitted + # #404 — explicit hierarchy parent. Natural format previously dropped this + # (only the internal mapping format honored it); now threaded to the ledger. + parent_decision_id: str | None = None # #109 — optional governance metadata threaded to the ledger. governance: dict | None = None diff --git a/docs/META_LEDGER.md b/docs/META_LEDGER.md index 4f83181..3b91914 100644 --- a/docs/META_LEDGER.md +++ b/docs/META_LEDGER.md @@ -2931,3 +2931,38 @@ SHA256(content_hash + previous_hash) **Infrastructure**: `qor/` Python package not installed in this worktree; gate artifact emission to `.qor/gates//research.json` skipped, brief stands on its content per the Entries #53-#54 precedent. **Required next action**: Governor authorizes cycle 8 implementation. When cycle 8 begins, the implementer must (a) link this brief in the PR description, (b) follow the Priority-1 / Priority-2 recommendations as the plan skeleton, (c) update the stale `sources/notion/poller.py` comment in the same PR, (d) gate on an adversarial `code-reviewer` pass. + +--- + +### Entry #56: GOVERNED CYCLE — #404 Phase 1 (natural-format parent_decision_id threading) + +**Timestamp**: 2026-06-01T00:00:00Z +**Phase**: substantiate (local governed cycle via `/qor-auto-dev-1`) +**Author**: Orchestrator (plan→audit→implement→substantiate); independent architect-reviewer audit (SG-007 author-bias mitigation) +**Risk Grade**: L2 — shared ingest contract (`IngestDecision`) + `_normalize_payload`; additive field, backward-compatible; no `ledger/schema.py` change → no §4.7 schema-codeowner gate. + +**Content Hash**: +``` +SHA256(plan-404-phase1-natural-format-parent-id.md) += adc582d1a454e8cc0b6cfefada109cb4080b686d493fb165a59bef6e637211a4 +``` + +**Previous Hash**: `144fcdccd63a7ca7e01712decc267bbe881dde4b19b2ac6ce5ae75b9c77fb7f6` (Entry #55) + +**Chain Hash**: +``` +SHA256(content_hash + previous_hash) += 44251a5052ddad6fc03441fce05a6311e7cab206439ac289858a4137ea9b4125 +``` + +**Scope**: #404 is a six-item, schema-migration-bearing P1; this cycle delivered **Phase 1 only** — the issue's self-described "smallest enabler" (items #4 + #5). Natural-format ingest now threads a caller-supplied `parent_decision_id` (previously honored only in the internal mapping format; silently dropped in natural format), and `_classify_decision_level`'s docstring documents the full classifier precedence (explicit `decision_level` wins → source-type heuristic → Phase-2 auto-derivation). Satisfies Acceptance criteria #1 and #5. + +**Files**: `contracts.py` (`IngestDecision.parent_decision_id`), `handlers/ingest.py` (`_normalize_payload` threading), `ledger/adapter.py` (`_classify_decision_level` docstring), `tests/test_404_natural_format_parent_id.py` (new sociable test, real adapter over `memory://`). Plan `plan-404-phase1-natural-format-parent-id.md` is gitignored per qor convention; locally hashable — content hash above. + +**Gate verdicts**: Audit **PASS** (independent architect-reviewer). The load-bearing daemon-serialization concern was verified safe: `handle_ingest` JSON-serializes the raw payload pre-transport (`handlers/ingest.py:845`), so `parent_decision_id` rides inside the opaque `payload` string and is normalized daemon-side via the shared `_handle_ingest_impl`. Substantiation: the new test is **load-bearing** — stashing the `_normalize_payload` threading makes the parent test FAIL (row persists `NONE`); restoring makes it PASS. `ruff check` + `ruff format --check` clean; `test_skill_governance_lint` green. + +**Decision**: Land Phase 1 at the single shared normalization point (`_normalize_payload` inside `_handle_ingest_impl`), which both the daemon `write.ingest` dispatcher and the MCP fallback execute — satisfying silongtan's #517 "land daemon-side" steer without a separate daemon edit. Deferred to later governed cycles: L1 auto-derivation + similarity-attach (Phase 2, daemon-side), rejected-alternatives-as-L3-negative-siblings (Phase 3), and the backfill migration (Phase 4 — carries the §4.7 `@jinhongkuan` schema-codeowner gate; the issue's proposed "v23→v24" must be renumbered because v24 is already taken by the input_span-dedup migration). + +**Infrastructure**: qor-logic upgraded 0.80.0 → 0.84.0 and redistributed to all three hosts this session (claude / codex / kilo-code, 63 files each). This entry's commit carries the canonical `Authored via [Qor-logic SDLC]` attribution trailer (the `qor` package is reachable via the CLI venv; the prior compact-trailer commits #393/#404-staged predate this fix). + +**Required next action**: User review + merge of the Phase-1 PR. Phase 2 (L1 auto-derivation) is the next governed cycle for #404. diff --git a/handlers/ingest.py b/handlers/ingest.py index 402cb4b..255288c 100644 --- a/handlers/ingest.py +++ b/handlers/ingest.py @@ -347,6 +347,10 @@ def _normalize_payload(payload: dict) -> dict: # #340 — thread decision_level from IngestDecision to the mapping. if d.decision_level is not None: mapping["decision_level"] = d.decision_level + # #404 — thread parent_decision_id (natural format previously dropped it; + # the adapter already reads mapping["parent_decision_id"] at ingest). + if d.parent_decision_id is not None: + mapping["parent_decision_id"] = d.parent_decision_id # #109 — thread optional governance metadata from IngestDecision # to the per-mapping payload so the ledger write picks it up. if d.governance is not None: diff --git a/ledger/adapter.py b/ledger/adapter.py index 1b73c86..4a49eb3 100644 --- a/ledger/adapter.py +++ b/ledger/adapter.py @@ -91,7 +91,15 @@ def _classify_decision_level(source_type: str, code_regions: list) -> str: """Deterministic heuristic for decision_level when the caller omits it. - Rules (applied in order): + Classifier precedence (#404 item 5) — overall order at the ingest call site: + 1. Explicit ``decision_level`` in the payload WINS — honored upstream at + the processing loop (the ``mapping.get("decision_level")`` guard); this + heuristic runs ONLY when the caller omits the field. + 2. This source-type heuristic (rules below). + 3. (#404 Phase 2, future) L1 auto-derivation, so no L2 lands without a + parent. + + Heuristic rules (case 2 only, applied in order): 1. Code regions present → L2 (architecture, code-grounded). 2. Source is a product conversation → L1 (product commitment). 3. Source is an implementation choice → L3 (technical detail). diff --git a/tests/test_404_natural_format_parent_id.py b/tests/test_404_natural_format_parent_id.py new file mode 100644 index 0000000..e0e0668 --- /dev/null +++ b/tests/test_404_natural_format_parent_id.py @@ -0,0 +1,81 @@ +"""Sociable tests for #404 Phase 1. + +Verifies that the natural-format ingest API (a) honors a caller-supplied +``parent_decision_id`` (previously silently dropped — only the internal mapping +format honored it) and (b) lets an explicit ``decision_level`` override the +source-type heuristic. + +Real ``SurrealDBLedgerAdapter`` over ``memory://`` (via ``BicameralContext``) and +the real ``_handle_ingest_impl`` — no ``MagicMock`` of ctx or ledger, per the +CLAUDE.md sociable-testing rule. The parent_decision_id test is load-bearing: +before ``_normalize_payload`` threads the field it never reaches the mapping, the +adapter writes ``NONE``, and the read-back assertion fails — so the test proves +the threading, not a mock. +""" + +from __future__ import annotations + +import pytest + +from context import BicameralContext +from handlers.ingest import _handle_ingest_impl + + +async def _get_client(ctx): + """Idiom from tests/test_ephemeral_authoritative.py::_get_client.""" + ledger = ctx.ledger + if hasattr(ledger, "connect"): + await ledger.connect() + inner = getattr(ledger, "_inner", ledger) + return inner._client + + +def _natural_payload(*, decision_level, parent_decision_id=None, source="agent_session"): + decision: dict = { + "description": "Redis-backed sessions for horizontal scale", + "source_excerpt": "we moved sessions to Redis so checkout could scale", + "decision_level": decision_level, + } + if parent_decision_id is not None: + decision["parent_decision_id"] = parent_decision_id + return { + "query": "checkout scaling", + "source": source, + "title": "sess-404-phase1", + "decisions": [decision], + } + + +@pytest.fixture +def mem_ctx(monkeypatch): + """A BicameralContext pinned to an in-memory ledger (never the real db).""" + monkeypatch.setenv("SURREAL_URL", "memory://") + return BicameralContext.from_env() + + +async def test_natural_format_honors_parent_decision_id(mem_ctx): + """An L2 decision ingested via natural format with an explicit + parent_decision_id persists that parent on the decision row.""" + payload = _natural_payload(decision_level="L2", parent_decision_id="decision:fake_parent_404") + resp = await _handle_ingest_impl(mem_ctx, payload) + assert resp.ingested, f"ingest failed: {resp}" + + decision_id = resp.created_decisions[0].decision_id + client = await _get_client(mem_ctx) + rows = await client.query(f"SELECT parent_decision_id FROM {decision_id} LIMIT 1") + assert rows, "decision row not found" + assert rows[0]["parent_decision_id"] == "decision:fake_parent_404" + + +async def test_explicit_decision_level_overrides_source_heuristic(mem_ctx): + """source='agent_session' would classify L3 via the heuristic; an explicit + decision_level='L1' in the payload must win (Acceptance #5, #340 precedence).""" + payload = _natural_payload(decision_level="L1", source="agent_session") + resp = await _handle_ingest_impl(mem_ctx, payload) + assert resp.ingested, f"ingest failed: {resp}" + + decision_id = resp.created_decisions[0].decision_id + client = await _get_client(mem_ctx) + rows = await client.query(f"SELECT decision_level FROM {decision_id} LIMIT 1") + assert rows, "decision row not found" + assert rows[0]["decision_level"] == "L1"