Files
openworker/coworker/teams/journal.py
T
Rohit C Prasad fc24b667ed Drop the proposed state: boards hold only accepted work
Plan proposals live in the conversation (plan-approval flow); items are created open/unassigned and work starts at assignment — the granted, revocable authority.
Also closes a verify gap: tail truncation is now caught against the stored head hash.
2026-08-16 08:07:10 -07:00

435 lines
16 KiB
Python

"""The journal store — case-keyed knowledge that outlives boards and teams.
Split from the board log on purpose (decided 2026-08-16): a board is a team-scoped
artifact and can be archived with its team, but a journal case follows the
INVESTIGATION — it may span two boards, survive a team, or belong to an Ops case no
board ever references. So cases live in their own store, hash-chained per case, with
their own grant table. What stays unified with the board is the record shape and the
discipline: attributed, timestamped, append-only, taint-flagged — the policy/audit
choke point is the API layer, not table co-location.
Access model: the user is never gated. Everyone else needs a grant on the case:
- creating a case (first append) grants its creator;
- assignment feeds grants automatically (assign an item carrying a case → the
assignee gains it; reassignment moves it) — "sharing rides assignment";
- explicit grants cover cross-team sharing.
Backing is SQLite for now (same as everything else in the state dir); the store is
deliberately small enough to swap the backing later without touching the verb
surface. Retrieval order stays: filters (here) → entity index → vectors as a
derived index.
"""
from __future__ import annotations
import json
import sqlite3
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from .model import (
JOURNAL_BODY_LIMIT,
JOURNAL_KINDS,
Actor,
AuthorityError,
BoardError,
ChainError,
Role,
)
from .store import GENESIS, _canonical, _hash
_HASHED_FIELDS = (
"ts",
"case_id",
"kind",
"actor",
"actor_role",
"space",
"item_id",
"payload",
"taint",
"prev_hash",
)
class JournalStore:
def __init__(self, db_path: str | Path) -> None:
self.db_path = str(db_path)
if self.db_path != ":memory:":
Path(self.db_path).expanduser().parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.executescript("""
CREATE TABLE IF NOT EXISTS journal_entries (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
case_id TEXT NOT NULL,
kind TEXT NOT NULL,
actor TEXT NOT NULL,
actor_role TEXT NOT NULL,
persona TEXT DEFAULT '',
model TEXT DEFAULT '',
session_id TEXT DEFAULT '',
space TEXT,
item_id INTEGER,
payload TEXT NOT NULL,
taint INTEGER NOT NULL DEFAULT 0,
prev_hash TEXT NOT NULL,
hash TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_journal_case
ON journal_entries (case_id, seq);
CREATE INDEX IF NOT EXISTS idx_journal_item
ON journal_entries (case_id, space, item_id, seq);
CREATE TABLE IF NOT EXISTS journal_grants (
case_id TEXT NOT NULL,
principal TEXT NOT NULL,
source TEXT NOT NULL,
space TEXT DEFAULT '',
item_id INTEGER,
UNIQUE (case_id, principal, source, space, item_id)
);
CREATE TABLE IF NOT EXISTS journal_meta (
case_id TEXT PRIMARY KEY,
head_hash TEXT NOT NULL,
created_ts TEXT NOT NULL
);
""")
self._conn.commit()
# ---------------------------------------------------------------------- verbs
def append(
self,
actor: Actor,
case: str,
body: str,
*,
kind: str = "note",
space: Optional[str] = None,
item: Optional[int] = None,
entities: Optional[list[str]] = None,
refs: Optional[list[str]] = None,
taint: bool = False,
) -> dict[str, Any]:
if not (case or "").strip():
raise BoardError("case is required")
if not (body or "").strip():
raise BoardError("entry body is required")
if kind not in JOURNAL_KINDS:
raise BoardError(f"unknown entry kind: {kind} (use one of {JOURNAL_KINDS})")
if len(body) > JOURNAL_BODY_LIMIT:
raise BoardError(
f"entry body over {JOURNAL_BODY_LIMIT} chars — save the full"
" capture to a file and journal an excerpt that references it"
)
with self._lock:
exists = self._case_exists(case)
if exists:
self._check_access(actor, case)
ts = datetime.now(timezone.utc).isoformat()
prev = self._head_hash(case)
record = {
"ts": ts,
"case_id": case,
"kind": kind,
"actor": actor.id,
"actor_role": actor.role.value,
"space": space,
"item_id": item,
"payload": _canonical(
{
"body": body,
"entities": sorted(set(entities or [])),
"refs": [str(ref) for ref in refs or []],
}
),
"taint": 1 if taint else 0,
"prev_hash": prev,
}
record["hash"] = _hash(record, fields=_HASHED_FIELDS)
try:
cursor = self._conn.execute(
"""
INSERT INTO journal_entries
(ts, case_id, kind, actor, actor_role, persona, model,
session_id, space, item_id, payload, taint, prev_hash, hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
ts,
case,
kind,
actor.id,
actor.role.value,
actor.persona,
actor.model,
actor.session_id,
space,
item,
record["payload"],
record["taint"],
prev,
record["hash"],
),
)
if not exists:
self._conn.execute(
"INSERT INTO journal_meta (case_id, head_hash, created_ts)"
" VALUES (?, ?, ?)",
(case, record["hash"], ts),
)
# A new case belongs to whoever opened it.
self._grant_locked(case, actor.id, source="creator")
else:
self._conn.execute(
"UPDATE journal_meta SET head_hash = ? WHERE case_id = ?",
(record["hash"], case),
)
self._conn.commit()
except Exception:
self._conn.rollback()
raise
return {**record, "seq": cursor.lastrowid}
def read(
self,
actor: Actor,
case: str,
*,
item: Optional[int] = None,
author: Optional[str] = None,
kind: Optional[str] = None,
entity: Optional[str] = None,
since_seq: int = 0,
include_raw: bool = False,
limit: int = 100,
) -> list[dict[str, Any]]:
"""Filtered read. `raw` captures are skipped unless asked for (by
`kind="raw"` or `include_raw`) so dumps never bury the signal entries."""
with self._lock:
self._check_access(actor, case)
where = ["case_id = ?", "seq > ?"]
params: list[Any] = [case, since_seq]
if item is not None:
where.append("item_id = ?")
params.append(item)
if author:
where.append("actor = ?")
params.append(author)
if kind:
if kind not in JOURNAL_KINDS:
raise BoardError(f"unknown entry kind: {kind}")
where.append("kind = ?")
params.append(kind)
elif not include_raw:
where.append("kind != 'raw'")
rows = self._conn.execute(
"SELECT * FROM journal_entries WHERE "
+ " AND ".join(where)
+ " ORDER BY seq",
params,
).fetchall()
out = []
cap = max(1, min(int(limit or 100), 1000))
for row in rows:
entry = _row_to_entry(row)
if entity and entity not in entry["entities"]:
continue
out.append(entry)
if len(out) >= cap:
break
return out
def overview(self, actor: Actor) -> list[dict[str, Any]]:
"""Case list with entry counts and last activity — the rail's summary view."""
visible = self.cases(actor)
if not visible:
return []
with self._lock:
rows = self._conn.execute(
"SELECT case_id, COUNT(*) AS entries, MAX(ts) AS last_ts"
" FROM journal_entries GROUP BY case_id"
).fetchall()
counts = {row["case_id"]: dict(row) for row in rows}
return [
{
"case": case,
"entries": counts.get(case, {}).get("entries", 0),
"last_ts": counts.get(case, {}).get("last_ts") or "",
}
for case in visible
]
def cases(self, actor: Actor) -> list[str]:
"""Cases visible to this actor (all of them for the user)."""
with self._lock:
if actor.role == Role.USER:
rows = self._conn.execute(
"SELECT case_id FROM journal_meta ORDER BY case_id"
).fetchall()
else:
rows = self._conn.execute(
"SELECT DISTINCT case_id FROM journal_grants WHERE principal = ?"
" ORDER BY case_id",
(actor.id,),
).fetchall()
return [row["case_id"] for row in rows]
# ---------------------------------------------------------------------- grants
def grant(self, actor: Actor, case: str, principal: str) -> None:
"""Explicit cross-team sharing. The user may grant any case; a lead may
grant cases it holds. Workers never grant — evidence flows up, access
flows down."""
if actor.role == Role.WORKER or actor.role == Role.SYSTEM:
raise AuthorityError("only the user or a lead may grant a case")
with self._lock:
if not self._case_exists(case):
raise BoardError(f"no case '{case}'")
if actor.role == Role.LEAD:
self._check_access(actor, case)
self._grant_locked(case, principal, source="grant")
self._conn.commit()
def revoke(self, actor: Actor, case: str, principal: str) -> None:
if actor.role == Role.WORKER or actor.role == Role.SYSTEM:
raise AuthorityError("only the user or a lead may revoke a case grant")
with self._lock:
if actor.role == Role.LEAD:
self._check_access(actor, case)
self._conn.execute(
"DELETE FROM journal_grants WHERE case_id = ? AND principal = ?"
" AND source = 'grant'",
(case, principal),
)
self._conn.commit()
def ensure_case(self, case: str, creator: str) -> None:
"""Create a case (empty, chain at genesis) if it doesn't exist, granting
its creator. Called by the board when an item attaches a case ref — so
the case belongs to whoever attached it, not to whichever assignee
happens to journal first. Standalone cases (no board) are still created
by their first append."""
if not (case or "").strip():
return
with self._lock:
if not self._case_exists(case):
self._conn.execute(
"INSERT INTO journal_meta (case_id, head_hash, created_ts)"
" VALUES (?, ?, ?)",
(case, GENESIS, datetime.now(timezone.utc).isoformat()),
)
self._grant_locked(case, creator, source="creator")
self._conn.commit()
def sync_assignment(
self,
case: str,
*,
space: str,
item_id: int,
assignee: str,
previous: str = "",
) -> None:
"""Called by the board on assign: access rides assignment. The previous
assignee loses the grant THIS item carried (grants from its other items
or explicit shares survive)."""
if not case:
return
with self._lock:
if previous:
self._conn.execute(
"DELETE FROM journal_grants WHERE case_id = ? AND principal = ?"
" AND source = 'assignment' AND space = ? AND item_id = ?",
(case, previous, space, item_id),
)
self._grant_locked(
case, assignee, source="assignment", space=space, item_id=item_id
)
self._conn.commit()
# ----------------------------------------------------------------- integrity
def verify_chain(self, case: str) -> int:
with self._lock:
rows = self._conn.execute(
"SELECT * FROM journal_entries WHERE case_id = ? ORDER BY seq",
(case,),
).fetchall()
prev = GENESIS
for row in rows:
record = {key: row[key] for key in _HASHED_FIELDS}
if row["prev_hash"] != prev:
raise ChainError(f"entry {row['seq']}: chain linkage broken")
if _hash(record, fields=_HASHED_FIELDS) != row["hash"]:
raise ChainError(f"entry {row['seq']}: content does not match hash")
prev = row["hash"]
# Tail truncation is invisible to the chain itself; the stored head sees it.
if rows and prev != self._head_hash(case):
raise ChainError("case log ends before the recorded head — tail deleted")
return len(rows)
def close(self) -> None:
self._conn.close()
# ------------------------------------------------------------------ internals
def _check_access(self, actor: Actor, case: str) -> None:
if actor.role == Role.USER:
return
row = self._conn.execute(
"SELECT 1 FROM journal_grants WHERE case_id = ? AND principal = ?"
" LIMIT 1",
(case, actor.id),
).fetchone()
if row is None:
raise AuthorityError(f"{actor.id} has no grant on case '{case}'")
def _grant_locked(
self,
case: str,
principal: str,
*,
source: str,
space: str = "",
item_id: Optional[int] = None,
) -> None:
self._conn.execute(
"INSERT OR IGNORE INTO journal_grants"
" (case_id, principal, source, space, item_id) VALUES (?, ?, ?, ?, ?)",
(case, principal, source, space, item_id),
)
def _case_exists(self, case: str) -> bool:
return (
self._conn.execute(
"SELECT 1 FROM journal_meta WHERE case_id = ?", (case,)
).fetchone()
is not None
)
def _head_hash(self, case: str) -> str:
row = self._conn.execute(
"SELECT head_hash FROM journal_meta WHERE case_id = ?", (case,)
).fetchone()
return row["head_hash"] if row else GENESIS
def _row_to_entry(row: sqlite3.Row) -> dict[str, Any]:
entry = dict(row)
try:
payload = json.loads(entry.pop("payload") or "{}")
except json.JSONDecodeError:
payload = {}
entry["body"] = payload.get("body")
entry["entities"] = payload.get("entities") or []
entry["refs"] = payload.get("refs") or []
entry["author"] = entry.pop("actor")
entry["role"] = entry.pop("actor_role")
entry["item"] = entry.pop("item_id")
return entry