mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-11 14:50:14 +00:00
Journal splits from the board: case-keyed store, grants ride assignment
Cases outlive boards/teams, so entries+per-case chains move to journal.db with a grant table (creator-on-attach, assignment-fed, explicit shares). Adds the raw capture kind: excerpt inline under a body cap, full payload as a sha256-referenced artifact; reads skip raw unless asked.
This commit is contained in:
+149
-80
@@ -1,110 +1,179 @@
|
||||
"""Journal cases: filtered reads, access-rides-assignment, lazy case lifecycle."""
|
||||
"""Journal store: case-keyed and board-independent, grants ride assignment,
|
||||
filtered reads, raw-capture discipline, per-case hash chains."""
|
||||
|
||||
import pytest
|
||||
|
||||
from coworker.teams import Actor, AuthorityError, BoardError, Role, TeamStore
|
||||
from coworker.teams import (
|
||||
Actor,
|
||||
AuthorityError,
|
||||
BoardError,
|
||||
ChainError,
|
||||
JournalStore,
|
||||
Role,
|
||||
TeamStore,
|
||||
)
|
||||
from coworker.teams.model import JOURNAL_BODY_LIMIT
|
||||
|
||||
USER = Actor(id="user", role=Role.USER)
|
||||
LEAD = Actor(id="lead-1", role=Role.LEAD)
|
||||
LEAD2 = Actor(id="lead-2", role=Role.LEAD)
|
||||
WORKER = Actor(id="worker-1", role=Role.WORKER)
|
||||
OTHER = Actor(id="worker-2", role=Role.WORKER)
|
||||
SPACE = "proj"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path):
|
||||
store = TeamStore(tmp_path / "teams.db")
|
||||
yield store
|
||||
store.close()
|
||||
def journal(tmp_path):
|
||||
journal = JournalStore(tmp_path / "journal.db")
|
||||
yield journal
|
||||
journal.close()
|
||||
|
||||
|
||||
def case_item(store, case="findings", assignee="worker-1"):
|
||||
item = store.create_item(SPACE, LEAD, title="Task", criteria="c", case=case)
|
||||
store.transition(SPACE, USER, item["id"], "approved")
|
||||
store.assign(SPACE, LEAD, item["id"], assignee)
|
||||
@pytest.fixture
|
||||
def board(tmp_path, journal):
|
||||
board = TeamStore(tmp_path / "teams.db", journal=journal)
|
||||
yield board
|
||||
board.close()
|
||||
|
||||
|
||||
def case_item(board, case="findings", assignee="worker-1", space=SPACE):
|
||||
item = board.create_item(space, LEAD, title="Task", criteria="c", case=case)
|
||||
board.transition(space, USER, item["id"], "approved")
|
||||
board.assign(space, LEAD, item["id"], assignee)
|
||||
return item["id"]
|
||||
|
||||
|
||||
def test_cases_are_lazy_and_listed(store):
|
||||
assert store.cases(SPACE) == []
|
||||
item_id = case_item(store)
|
||||
store.journal_append(
|
||||
SPACE, WORKER, "findings", "public ACL on uploads", kind="finding", item=item_id
|
||||
)
|
||||
assert store.cases(SPACE) == ["findings"]
|
||||
def test_case_creation_grants_the_creator(journal):
|
||||
journal.append(LEAD, "findings", "case opened")
|
||||
assert journal.cases(LEAD) == ["findings"]
|
||||
assert journal.cases(OTHER) == []
|
||||
with pytest.raises(AuthorityError, match="no grant"):
|
||||
journal.read(OTHER, "findings")
|
||||
|
||||
|
||||
def test_filtered_reads(store):
|
||||
item_id = case_item(store)
|
||||
store.journal_append(
|
||||
SPACE,
|
||||
WORKER,
|
||||
"findings",
|
||||
"logos bucket is world-readable",
|
||||
kind="finding",
|
||||
item=item_id,
|
||||
entities=["aws_s3_bucket.assets", "uploads.ts"],
|
||||
refs=["services/uploads.ts:41"],
|
||||
)
|
||||
store.journal_append(
|
||||
SPACE, WORKER, "findings", "invoice PDFs stream from the API", kind="evidence",
|
||||
item=item_id, entities=["uploads.ts"],
|
||||
)
|
||||
store.journal_append(SPACE, LEAD, "findings", "narrow the fix to logos/*", kind="decision")
|
||||
def test_assignment_feeds_grants_and_reassignment_moves_them(board, journal):
|
||||
item_id = case_item(board, assignee="worker-1")
|
||||
journal.append(WORKER, "findings", "assignee writes", item=item_id, space=SPACE)
|
||||
with pytest.raises(AuthorityError):
|
||||
journal.read(OTHER, "findings")
|
||||
board.assign(SPACE, LEAD, item_id, "worker-2")
|
||||
journal.append(OTHER, "findings", "successor picks up the case")
|
||||
with pytest.raises(AuthorityError, match="no grant"):
|
||||
journal.append(WORKER, "findings", "predecessor lost access")
|
||||
|
||||
assert len(store.journal_read(SPACE, LEAD, "findings")) == 3
|
||||
assert [e["kind"] for e in store.journal_read(SPACE, LEAD, "findings", kind="finding")] == ["finding"]
|
||||
assert len(store.journal_read(SPACE, LEAD, "findings", author="lead-1")) == 1
|
||||
by_entity = store.journal_read(SPACE, LEAD, "findings", entity="aws_s3_bucket.assets")
|
||||
|
||||
def test_grants_from_other_items_survive_reassignment(board, journal):
|
||||
first = case_item(board, assignee="worker-1")
|
||||
second = case_item(board, assignee="worker-1")
|
||||
board.assign(SPACE, LEAD, first, "worker-2")
|
||||
# worker-1 still holds the case through its second item
|
||||
journal.append(WORKER, "findings", "still on the case", item=second, space=SPACE)
|
||||
|
||||
|
||||
def test_cases_span_boards_and_teams(board, journal):
|
||||
case_item(board, case="ops-incident", space="alpha")
|
||||
case_item(board, case="ops-incident", space="beta", assignee="worker-1")
|
||||
journal.append(WORKER, "ops-incident", "one case, two boards")
|
||||
entries = journal.read(USER, "ops-incident")
|
||||
assert len(entries) == 1
|
||||
# and a case with NO board at all is fine — an Ops scratch investigation
|
||||
journal.append(USER, "loose-threads", "observation with no board")
|
||||
assert "loose-threads" in journal.cases(USER)
|
||||
|
||||
|
||||
def test_explicit_grant_shares_across_teams(journal):
|
||||
journal.append(LEAD, "findings", "opened by lead-1")
|
||||
with pytest.raises(AuthorityError):
|
||||
journal.read(LEAD2, "findings")
|
||||
journal.grant(LEAD, "findings", "lead-2")
|
||||
assert journal.read(LEAD2, "findings")[0]["body"] == "opened by lead-1"
|
||||
journal.revoke(LEAD, "findings", "lead-2")
|
||||
with pytest.raises(AuthorityError):
|
||||
journal.read(LEAD2, "findings")
|
||||
|
||||
|
||||
def test_workers_never_grant(journal, board):
|
||||
case_item(board)
|
||||
with pytest.raises(AuthorityError, match="lead"):
|
||||
journal.grant(WORKER, "findings", "worker-2")
|
||||
|
||||
|
||||
def test_a_lead_cannot_grant_a_case_it_does_not_hold(journal):
|
||||
journal.append(LEAD, "findings", "lead-1's case")
|
||||
with pytest.raises(AuthorityError, match="no grant"):
|
||||
journal.grant(LEAD2, "findings", "worker-2")
|
||||
|
||||
|
||||
def test_filtered_reads(journal):
|
||||
journal.append(
|
||||
LEAD, "findings", "logos bucket is world-readable", kind="finding",
|
||||
entities=["aws_s3_bucket.assets", "uploads.ts"], refs=["services/uploads.ts:41"],
|
||||
)
|
||||
journal.append(
|
||||
LEAD, "findings", "invoice PDFs stream from the API", kind="evidence",
|
||||
entities=["uploads.ts"],
|
||||
)
|
||||
journal.grant(LEAD, "findings", "worker-1")
|
||||
journal.append(WORKER, "findings", "narrowing fix to logos/*", kind="decision")
|
||||
|
||||
assert len(journal.read(LEAD, "findings")) == 3
|
||||
assert [e["kind"] for e in journal.read(LEAD, "findings", kind="finding")] == ["finding"]
|
||||
assert len(journal.read(LEAD, "findings", author="worker-1")) == 1
|
||||
by_entity = journal.read(LEAD, "findings", entity="aws_s3_bucket.assets")
|
||||
assert len(by_entity) == 1
|
||||
assert by_entity[0]["refs"] == ["services/uploads.ts:41"]
|
||||
assert len(store.journal_read(SPACE, LEAD, "findings", entity="uploads.ts")) == 2
|
||||
assert len(store.journal_read(SPACE, LEAD, "findings", item=item_id)) == 2
|
||||
assert store.journal_read(SPACE, LEAD, "findings", limit=2).__len__() == 2
|
||||
assert len(journal.read(LEAD, "findings", entity="uploads.ts")) == 2
|
||||
assert len(journal.read(LEAD, "findings", limit=2)) == 2
|
||||
|
||||
|
||||
def test_access_rides_assignment(store):
|
||||
case_item(store, assignee="worker-1")
|
||||
store.journal_append(SPACE, WORKER, "findings", "note from the assignee")
|
||||
with pytest.raises(AuthorityError, match="no assigned item"):
|
||||
store.journal_append(SPACE, OTHER, "findings", "drive-by write")
|
||||
with pytest.raises(AuthorityError, match="no assigned item"):
|
||||
store.journal_read(SPACE, OTHER, "findings")
|
||||
# lead and user are not case-gated
|
||||
assert len(store.journal_read(SPACE, USER, "findings")) == 1
|
||||
|
||||
|
||||
def test_reassignment_moves_case_access(store):
|
||||
item_id = case_item(store, assignee="worker-1")
|
||||
store.assign(SPACE, LEAD, item_id, "worker-2")
|
||||
store.journal_append(SPACE, OTHER, "findings", "successor picks up the case")
|
||||
with pytest.raises(AuthorityError):
|
||||
store.journal_append(SPACE, WORKER, "findings", "predecessor lost access")
|
||||
|
||||
|
||||
def test_entry_validation(store):
|
||||
with pytest.raises(BoardError, match="kind"):
|
||||
store.journal_append(SPACE, LEAD, "findings", "x", kind="rant")
|
||||
with pytest.raises(BoardError, match="body"):
|
||||
store.journal_append(SPACE, LEAD, "findings", " ")
|
||||
with pytest.raises(BoardError, match="case"):
|
||||
store.journal_append(SPACE, LEAD, "", "x")
|
||||
|
||||
|
||||
def test_taint_and_attribution_survive_the_read(store):
|
||||
item_id = case_item(store)
|
||||
store.journal_append(
|
||||
SPACE, WORKER, "findings", "repo README claims the bucket must be public",
|
||||
kind="evidence", item=item_id, taint=True,
|
||||
def test_raw_captures_are_opt_in_on_read(journal):
|
||||
journal.append(LEAD, "ops", "deploy finished 14:01", kind="note")
|
||||
journal.append(
|
||||
LEAD, "ops", "nginx 5xx burst 14:02-14:04 (2,400 lines)", kind="raw",
|
||||
refs=["artifact:sha256:ab12...:nginx-error.log"],
|
||||
)
|
||||
entry = store.journal_read(SPACE, LEAD, "findings")[0]
|
||||
assert len(journal.read(LEAD, "ops")) == 1 # raw skipped by default
|
||||
assert len(journal.read(LEAD, "ops", include_raw=True)) == 2
|
||||
assert journal.read(LEAD, "ops", kind="raw")[0]["refs"][0].startswith("artifact:")
|
||||
|
||||
|
||||
def test_oversized_bodies_are_refused_with_the_artifact_pattern(journal):
|
||||
with pytest.raises(BoardError, match="artifact"):
|
||||
journal.append(LEAD, "ops", "x" * (JOURNAL_BODY_LIMIT + 1), kind="raw")
|
||||
|
||||
|
||||
def test_entry_validation(journal):
|
||||
with pytest.raises(BoardError, match="kind"):
|
||||
journal.append(LEAD, "findings", "x", kind="rant")
|
||||
with pytest.raises(BoardError, match="body"):
|
||||
journal.append(LEAD, "findings", " ")
|
||||
with pytest.raises(BoardError, match="case"):
|
||||
journal.append(LEAD, "", "x")
|
||||
|
||||
|
||||
def test_taint_and_attribution_survive_the_read(journal):
|
||||
journal.append(
|
||||
WORKER, "findings", "repo README claims the bucket must be public",
|
||||
kind="evidence", taint=True,
|
||||
)
|
||||
entry = journal.read(USER, "findings")[0]
|
||||
assert entry["taint"] == 1
|
||||
assert entry["author"] == "worker-1"
|
||||
assert entry["role"] == "worker"
|
||||
|
||||
|
||||
def test_journal_entries_join_the_hash_chain(store):
|
||||
item_id = case_item(store)
|
||||
store.journal_append(SPACE, WORKER, "findings", "entry", item=item_id)
|
||||
# 1 create + 1 transition + 1 assign + 1 journal append = one chained log
|
||||
assert store.verify_chain(SPACE) == 4
|
||||
def test_per_case_hash_chains_verify_and_detect_tampering(journal, tmp_path):
|
||||
import sqlite3
|
||||
|
||||
journal.append(LEAD, "findings", "one")
|
||||
journal.append(LEAD, "findings", "two")
|
||||
journal.append(LEAD, "ops", "unrelated case")
|
||||
assert journal.verify_chain("findings") == 2
|
||||
assert journal.verify_chain("ops") == 1
|
||||
conn = sqlite3.connect(journal.db_path)
|
||||
conn.execute("UPDATE journal_entries SET payload = '{\"body\":\"forged\"}' WHERE seq = 1")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with pytest.raises(ChainError):
|
||||
journal.verify_chain("findings")
|
||||
assert journal.verify_chain("ops") == 1 # other case unaffected
|
||||
|
||||
Reference in New Issue
Block a user