Skip to content
This repository was archived by the owner on May 26, 2026. It is now read-only.
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
35 changes: 35 additions & 0 deletions BUILD_DEVIATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,41 @@ Format:

## Open

### D-kr2-st4-no-chain-emit-mcp-tool

- **Bucket**: KR-2 ST4 (chain event emission + recent events read + finalize)
- **Why**: Spec § ST4 § 1 mandates chain events go through a Sea MCP
tool (working name `kora__append_event`) — direct INSERT into
`hivex_foundation.event_log` is forbidden because it would skip the
substrate's `_emit_chain_event` SECDEF (which sets `prev_event_hash` /
`this_event_hash` to maintain chain witness integrity). The Sea MCP
server on substrate main `28ff4f78` exposes only
`kora__propose_convention`, `kora__read_escalation_queue`,
`kora__propose_policy_change` — no append-event tool. Same pattern
as the ST3 scratchpad-write deferral.
- **Closes when**: A Sea MCP append-event tool ships (working name
`kora__append_event`; PM coordinates with substrate-team / files
the substrate dispatch — likely K-9 on CC#1's lane, queued behind
K-7 + K-8). When it lands, `events.emit_kora_event` body switches
from `raise ChainEventEmitNotAvailableError()` to
`mcp_client.invoke('kora__append_event', ...)`. Caller signature
stays unchanged — `provider._attempt_chain_event_emit` and every
lifecycle hook that uses it (`sync_turn`, `on_memory_write`,
`on_session_end`, `on_delegation`) keep working without refactor.
- **Guarded by**:
- `plugins/memory/isokron/events.py` — top-of-module `[kora.isokron.todo]`
tag; `ChainEventEmitNotAvailableError` carries the deviation ID in
every raised message.
- `IsoKronMemoryProvider._attempt_chain_event_emit` — catches
`ChainEventEmitNotAvailableError` + logs a one-line WARNING
tagged with the deviation ID and the event_type that was skipped.
Operators grep `D-kr2-st4-no-chain-emit-mcp-tool` in logs.
- `plugins/memory/isokron/README.md` § "Operator pitfalls" —
chain event deferral notice.
- `tests/plugins/memory/test_events.py` —
`test_emit_kora_event_raises_deferred_write_error` asserts the
error message + tag stay correct.

### D-kr2-st3-no-scratchpad-write-mcp-tool

- **Bucket**: KR-2 ST3 (Scratchpad reads + writes)
Expand Down
10 changes: 8 additions & 2 deletions plugins/memory/isokron/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,17 @@ This `README.md` ships with **KR-2 ST1**, which delivers the structural skeleton
|---|---|
| ST1 | skeleton; config schema; connection plumbing (IO loop, no real handshakes); plugin discovery wiring; smoke tests |
| ST2 | reads: `read_active_role_charter` (SHA-256 integrity, asyncpg), `read_kora_capability_row` (C2 Python mirror — see "Operator pitfalls"), `read_kora_policy_registry` (RLS GUC + 31-row sanity, asyncpg); 60s TTL cache wired per-workspace; `system_prompt_block` assembles identity / CAN / CANNOT / active policies / granted caps / Rule-6 honest-label; `on_turn_start` pre-fetches all three in parallel via `asyncio.gather` |
| ST3 (this PR) | scratchpad reads (`read_own_scratchpad`, `read_cross_agent_scratchpad`) against `kronicle.agent_scratchpad_entries` JOINing `public.actor_registry` for `actor_kind` / `display_name`; RLS-GUC-in-transaction; BLAKE3 integrity warn-on-mismatch; cached 60s per workspace. **Writes deferred** behind `ScratchpadWriteNotAvailableError` — see "Operator pitfalls" and `BUILD_DEVIATIONS.md` D-kr2-st3-no-scratchpad-write-mcp-tool. `sync_turn` + `on_memory_write` attempt writes through the deferred surface + catch the error gracefully. |
| ST4 | `kora.*` chain event emission via Sea MCP `append_event`; recent events read from `hivex_foundation.event_log`; E2E test removes the last `NotImplementedError` stubs |
| ST3 | scratchpad reads (`read_own_scratchpad`, `read_cross_agent_scratchpad`) against `kronicle.agent_scratchpad_entries` JOINing `public.actor_registry` for `actor_kind` / `display_name`; RLS-GUC-in-transaction; BLAKE3 integrity warn-on-mismatch; cached 60s per workspace. **Writes deferred** behind `ScratchpadWriteNotAvailableError` — see "Operator pitfalls" and `BUILD_DEVIATIONS.md` D-kr2-st3-no-scratchpad-write-mcp-tool. `sync_turn` + `on_memory_write` attempt writes through the deferred surface + catch the error gracefully. |
| ST4 (this PR) | Recent `kora.*` chain events read against `hivex_foundation.event_log` (the substrate's one tenant_id-UUID-keyed table; JOIN tenant on clerk_org_id). Active Constitution revision read against `kronicle.workspace_constitution_revisions` (no `superseded_at`; ORDER BY revision_number DESC). `KoraSessionContext` shape + assembler mirroring the TS-side types.ts:130 contract. System prompt block extended with §6 "Recent kora.* activity". All six remaining ABC stubs replaced with real implementations. **Chain event emit deferred** behind `ChainEventEmitNotAvailableError` — see "Operator pitfalls" and `BUILD_DEVIATIONS.md` D-kr2-st4-no-chain-emit-mcp-tool. **KR-2 milestone closes.** |

## Operator pitfalls

* **Chain event emission is currently deferred — `kora.*` events are NOT being written to `event_log`.** KR-2 ST4 ships the emit API (`events.emit_kora_event`) but the substrate-side Sea MCP tool (`kora__append_event` or equivalent) doesn't exist yet (substrate main `28ff4f78`). Until it lands, every emit raises `ChainEventEmitNotAvailableError`; `sync_turn` / `on_memory_write` / `on_delegation` / `on_session_end` catch it + log a one-line WARNING tagged `D-kr2-st4-no-chain-emit-mcp-tool`. This is a chain-audit gap — operators inspecting Kora's recent activity via the `system_prompt_block` §6 section will see only events that landed in `event_log` through other paths (e.g. SECDEF-emitted events from `compact_scratchpad`). Direct INSERT into `event_log` is forbidden — it would skip the `_emit_chain_event` SECDEF and break the `prev_event_hash` / `this_event_hash` witness chain. Tracked in `BUILD_DEVIATIONS.md`.

* **`event_log` is the one genuine `tenant_id UUID`-keyed substrate table.** Every other Kora table (`kora_role_charter`, `kora_policy_registry`, `kronicle.agent_scratchpad_entries`, `kronicle.workspace_constitution_revisions`) is `workspace_id TEXT`-keyed. `read_recent_kora_events` resolves the workspace_id (Clerk `org_*`) to tenant_id via `JOIN hivex_foundation.tenant ON t.clerk_org_id = $1`. If you bypass `events.read_recent_kora_events` and write your own SQL, replicate the JOIN — a `WHERE workspace_id = $1` against `event_log` will fail (no such column on that table).

* **Constitution revision table has no `superseded_at` column.** Earlier bucket prompts referenced `WHERE superseded_at IS NULL` which would error with "column does not exist". The actual "active" semantic for `kronicle.workspace_constitution_revisions` is `ORDER BY revision_number DESC LIMIT 1` (riding the `idx_constitution_revisions_workspace_current` index). The `read_active_constitution_revision` reader gets this right; if you query the table directly, copy the SQL from `constitution.py:SELECT_ACTIVE_CONSTITUTION_REVISION_SQL`.

* **Scratchpad writes are currently deferred — sessions still run but lose their reasoning trail.** KR-2 ST3 ships the write API (`scratchpad.write_scratchpad_entry`) but the substrate-side Sea MCP tool (`kora__write_agent_scratchpad`) doesn't exist yet (substrate main `a3e77f67`). Until it lands, every write raises `ScratchpadWriteNotAvailableError`; `sync_turn` and `on_memory_write` catch it + log a one-line WARNING tagged with the BUILD_DEVIATIONS ID. Grep `D-kr2-st3-no-scratchpad-write-mcp-tool` in logs to see how often writes are being deferred. Reads (own + cross-agent) work fully. Tracked in `BUILD_DEVIATIONS.md`. The spec is explicit: do NOT bypass with direct INSERT — that would skip `cap_write_agent_scratchpad` authorization + the `approved_event_id` chain event + visibility_scope validation.

* **Scratchpad BLAKE3 integrity is warn-only, NOT fail-closed.** Unlike the Role Charter (which raises on hash mismatch), `read_own_scratchpad` and `read_cross_agent_scratchpad` log a WARNING and return the entry on mismatch. Spec § ST3: scratchpad is mutable working memory; refusing to surface a drifted entry would block sessions on transient state. Operators monitoring chain-of-custody should grep `content_hash drift` in logs.
Expand Down
1 change: 1 addition & 0 deletions plugins/memory/isokron/capability_matrix_mirror.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@
# Operator-direct admin caps (operator-ONLY)
"cap_operator_approve_policy_change": False,
"cap_operator_bless_convention": False,
"cap_unbless_convention": False, # operator-only un-bless path
"cap_operator_ack_escalation": False,
"cap_operator_update_policy": False,
}
Expand Down
111 changes: 111 additions & 0 deletions plugins/memory/isokron/constitution.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""Active Constitution revision read (KR-2 ST4).

Reads the workspace's currently-active Constitution revision from
``kronicle.workspace_constitution_revisions`` (foundation/0083).

**Schema gotcha** verified against the migration on substrate main:

- The table has NO ``superseded_at`` column. Earlier bucket-prompt
drafts (and PM dispatches) referenced ``WHERE superseded_at IS NULL``
— that would fail with "column does not exist". The actual "active"
semantic is ``ORDER BY revision_number DESC LIMIT 1``, riding the
``idx_constitution_revisions_workspace_current`` index.
- ``rules_hash`` is ``BYTEA`` (raw bytes), not pre-hex-encoded. We
hex-encode here to match the TS-side
``constitutionVersionHashFromRulesHash`` helper so K-3 (TS
context-assembler) and KR-2 (Python provider) emit the same
canonical hex form for K-6's Constitution pre-screen middleware.
- RLS is enabled keyed off ``current_setting('app.current_workspace_id')``
— same pattern as ``kora_policy_registry`` and ``agent_scratchpad_entries``.
The reader uses the GUC-in-transaction pattern from ST2/ST3.
"""

from __future__ import annotations

import logging
from dataclasses import dataclass
from typing import Any, Optional

logger = logging.getLogger(__name__)


@dataclass(frozen=True, slots=True)
class ActiveConstitutionRevision:
"""Active Constitution revision for a workspace.

Mirrors TS-side ``ActiveConstitutionRevision`` from
``packages/sea-mcp-server/src/kora/context-assembler/index.ts:386``.

``revision_id`` is the UUID PK; ``rules_hash`` is hex-encoded so
it's canonically comparable to the same hash computed elsewhere
in the stack (K-6 Constitution pre-screen middleware, TS-side
``constitutionVersionHashFromRulesHash``).
"""

revision_id: str
rules_hash: str # hex-encoded


SELECT_ACTIVE_CONSTITUTION_REVISION_SQL = """
SELECT
revision_id::text AS revision_id,
rules_hash AS rules_hash
FROM kronicle.workspace_constitution_revisions
WHERE workspace_id = $1
ORDER BY revision_number DESC
LIMIT 1
"""


def _hex_encode_rules_hash(value: Any) -> str:
"""Normalize ``rules_hash`` from BYTEA / memoryview / str to hex.

asyncpg returns BYTEA as ``bytes``; mocks in tests may return
a hex string already (pass-through). ``memoryview`` is the
asyncpg-bytea-buffer subtype on some versions.
"""
if isinstance(value, (bytes, bytearray)):
return value.hex()
if isinstance(value, memoryview):
return bytes(value).hex()
if isinstance(value, str):
return value
raise TypeError(
f"[kora.isokron] rules_hash arrived as unexpected type "
f"{type(value).__name__}; expected bytes / memoryview / str."
)


async def read_active_constitution_revision(
workspace_id: str,
pool: Any,
) -> Optional[ActiveConstitutionRevision]:
"""Return the active Constitution revision, or ``None`` for fresh workspaces.

Fresh-workspace bootstrap state: a workspace that has not yet
authored any Constitution revisions has zero rows in this table.
Returning ``None`` (vs raising) matches the TS-side behavior;
callers either fall back to the platform-default Constitution
(PLAT-* rules in ``config/kronicle/default_constitution.yaml``) or
skip Constitution-dependent pre-screening for that turn.

The RLS policy keys off ``app.current_workspace_id`` — same GUC
pattern as ``kora_policy_registry`` and ``agent_scratchpad_entries``.
Without the GUC set, the query returns zero rows silently;
set it in a transaction before the SELECT.
"""
async with pool.acquire() as conn:
async with conn.transaction():
await conn.execute(
"SELECT set_config('app.current_workspace_id', $1, true)",
workspace_id,
)
row = await conn.fetchrow(
SELECT_ACTIVE_CONSTITUTION_REVISION_SQL, workspace_id
)
if row is None:
return None
return ActiveConstitutionRevision(
revision_id=row["revision_id"],
rules_hash=_hex_encode_rules_hash(row["rules_hash"]),
)
192 changes: 192 additions & 0 deletions plugins/memory/isokron/events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
"""Chain event emit + recent events read (KR-2 ST4).

Two halves:

- :func:`read_recent_kora_events` — direct asyncpg read against
``hivex_foundation.event_log`` filtered to ``event_type LIKE 'kora.%'``.
``event_log`` is **tenant_id UUID keyed** (the one genuine substrate
exception to the workspace_id TEXT pattern, per foundation/0003); the
query resolves the caller's ``workspace_id`` (Clerk ``org_*`` TEXT)
to ``tenant_id`` via ``JOIN hivex_foundation.tenant ON
t.clerk_org_id = $1``. Matches the TS reader at
``packages/sea-mcp-server/src/kora/context-assembler/index.ts:287``.

- :func:`emit_kora_event` — deferred write surface. Chain events go
through the substrate's ``_emit_chain_event`` SECDEF (which sets
``prev_event_hash`` / ``this_event_hash`` to maintain chain witness
integrity); calling it from runtime Python without the SECDEF wrapper
would break the witness chain. The path is a Sea MCP tool
(working name ``kora__append_event``); as of substrate main
``28ff4f78``, no such tool is registered. Raises
:class:`ChainEventEmitNotAvailableError` until the tool ships.
BUILD_DEVIATIONS ``D-kr2-st4-no-chain-emit-mcp-tool``.
"""

from __future__ import annotations

import logging
from dataclasses import dataclass
from typing import Any, Optional

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------


class ChainEventEmitNotAvailableError(RuntimeError):
"""Raised by :func:`emit_kora_event` until the Sea MCP tool ships.

Same pattern as :class:`scratchpad.ScratchpadWriteNotAvailableError`
— runtime callers MUST NOT bypass with direct INSERT or
``_emit_chain_event`` calls (would break chain witness integrity).
"""

DEFAULT_MESSAGE = (
"[kora.isokron.todo] chain event emit deferred — Sea MCP server "
"does not yet expose kora__append_event (or equivalent). Tracked "
"in BUILD_DEVIATIONS.md as D-kr2-st4-no-chain-emit-mcp-tool. "
"Direct INSERT into hivex_foundation.event_log bypasses the "
"prev_event_hash / this_event_hash chain — do NOT do that; the "
"MCP tool wraps the substrate's _emit_chain_event SECDEF which "
"preserves chain witness integrity."
)

def __init__(self, message: Optional[str] = None):
super().__init__(message or self.DEFAULT_MESSAGE)


# ---------------------------------------------------------------------------
# Typed shape
# ---------------------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class RecentChainEvent:
"""One ``kora.*`` chain event from ``hivex_foundation.event_log``.

Mirrors TS-side ``KoraRecentChainEvent`` in
``packages/sea-mcp-server/src/kora/context-assembler/types.ts:72``.
``payload_summary`` is ``jsonb_pretty(payload)`` truncated to 300
chars + ellipsis — keeps system prompt bullets readable without
bloating the prompt on operator-extended payloads.
"""

event_id: str
event_type: str
occurred_at: str # ISO-8601 from TIMESTAMPTZ
payload_summary: str


# ---------------------------------------------------------------------------
# Read SQL — matches TS reader verbatim
# ---------------------------------------------------------------------------

SELECT_RECENT_KORA_CHAIN_EVENTS_SQL = """
SELECT
el.event_id::text AS event_id,
el.event_type AS event_type,
el.occurred_at AS occurred_at,
jsonb_pretty(el.payload)::text AS payload_text
FROM hivex_foundation.event_log el
JOIN hivex_foundation.tenant t ON t.tenant_id = el.tenant_id
WHERE t.clerk_org_id = $1
AND el.event_type LIKE 'kora.%'
ORDER BY el.occurred_at DESC
LIMIT $2
"""

DEFAULT_RECENT_EVENT_LIMIT = 50
"""Per spec § ST4 + TS-side ``DEFAULT_RECENT_EVENT_LIMIT``."""

RECENT_EVENT_PAYLOAD_TRUNCATE_LENGTH = 300
"""Per TS-side ``RECENT_EVENT_PAYLOAD_TRUNCATE_LENGTH``. Truncated
``jsonb_pretty`` output preserves the leading structural lines so an
operator skimming the system prompt can still see what each event
carried, without bloating long payloads."""


# ---------------------------------------------------------------------------
# Read path
# ---------------------------------------------------------------------------


def _truncate_payload(payload_text: str) -> str:
if len(payload_text) <= RECENT_EVENT_PAYLOAD_TRUNCATE_LENGTH:
return payload_text
return payload_text[:RECENT_EVENT_PAYLOAD_TRUNCATE_LENGTH] + "…"


def _project_event(row: dict[str, Any]) -> RecentChainEvent:
occurred_at = row["occurred_at"]
if hasattr(occurred_at, "isoformat"):
occurred_at_iso = occurred_at.isoformat()
else:
occurred_at_iso = str(occurred_at)
return RecentChainEvent(
event_id=row["event_id"],
event_type=row["event_type"],
occurred_at=occurred_at_iso,
payload_summary=_truncate_payload(row["payload_text"] or ""),
)


async def read_recent_kora_events(
workspace_id: str,
pool: Any,
*,
limit: int = DEFAULT_RECENT_EVENT_LIMIT,
) -> list[RecentChainEvent]:
"""Read recent ``kora.*`` chain events for the workspace.

Note: ``hivex_foundation.event_log`` keys off ``tenant_id UUID``,
not ``workspace_id TEXT`` like the other Kora tables. The JOIN
resolves the workspace_id (Clerk ``org_*``) to tenant_id via
``hivex_foundation.tenant.clerk_org_id``. The TS reader at
``packages/sea-mcp-server/src/kora/context-assembler/index.ts:287``
uses the same JOIN; this is the substrate-team's established
resolution pattern.

No RLS GUC needed here: ``event_log``'s row-security model uses
different machinery (per-partition partial indexes + foreign-key
constraints to tenant); the JOIN itself enforces tenant isolation
via the workspace_id binding.
"""
async with pool.acquire() as conn:
rows = await conn.fetch(
SELECT_RECENT_KORA_CHAIN_EVENTS_SQL, workspace_id, limit
)
return [_project_event(dict(row)) for row in rows]


# ---------------------------------------------------------------------------
# Emit path (deferred — see BUILD_DEVIATIONS D-kr2-st4-no-chain-emit-mcp-tool)
# ---------------------------------------------------------------------------


async def emit_kora_event(
*,
workspace_id: str,
event_type: str,
payload: Any,
mcp_client: Any = None,
) -> str:
"""Emit a ``kora.*`` chain event via the Sea MCP tool surface.

Raises ``ChainEventEmitNotAvailableError`` until
``kora__append_event`` (or equivalent) lands in the Sea MCP
server. Caller signature matches the future MCP-backed
implementation; when the tool ships the body switches to an
``mcp_client.invoke('kora__append_event', ...)`` call without
any caller-side refactor.

``event_type`` must start with ``kora.`` and appear in the
``event_log_event_type_check`` constraint set (foundation/0136 +
foundation/0138 ship the canonical vocabulary). Validation is
enforced substrate-side by the MCP tool — runtime callers pass
the literal through.
"""
del workspace_id, event_type, payload, mcp_client
raise ChainEventEmitNotAvailableError()
Loading