Skip to content
Closed
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ POSTGRES_DB=lineageweave
POSTGRES_PORT=15432

VALKEY_PORT=16379
# Optional. 1 = fail-closed Valkey outbox (ADR 0026). Never invent a
# delivery. Default unset uses compose Valkey.
VALKEY_DISABLED=

KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=admin_dev_only
Expand Down
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ Gabriel factorization. Closest and farthest post–criterion pairs
persist to `report_leftover_pair` and sit above the member list so
a click opens that post.

Ticket activity is a transactional outbox (ADR 0026). Persist
`activity_outbox_event` first, then `XADD`. `GET /api/outbox`
fail-closes when Valkey is down. Never invent a stream id or a
theta. A hidden post is omitted from the home list.

`frontend/` has its own toolchain (Node pinned via `frontend/mise.toml`,
pnpm via Corepack -- do not add a second Node package manager or a
floating Node version):
Expand Down
14 changes: 13 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,19 @@ create, `ticket_status_changed` on a status-changing `PATCH`) as the
first real producer, and surfaced in the popup as an `ActivityPanel`
(list + manual refresh) as the first real consumer. `make seed` XADDs
`ticket_created` for the seeded A-100 and calendar tickets so Activity
is not empty after a report-member click. Verified against
is not empty after a report-member click.

v0.85.0 (ADR 0026) adds the transactional outbox those dual-writes
were missing. `activity_outbox_event` is persisted as
`outbox_pending` before any `XADD`; a successful stream id flips the
row to `outbox_delivered`. `GET /api/outbox` fail-closes when
`VALKEY_DISABLED=1` or Valkey does not answer. Home Outbox sits
between Rankings and Calendar: unavailable copy is **Outbox · Valkey
not available**; an accepted delivery lists the summary and opens
that post. Hidden posts are omitted. Never invent a stream id or a
theta. TEPP's measurement outbox stays on #214.

Verified against
the actual Docker Compose network, not just `pytest`: created and
patched a ticket through the real `backend` container talking to the
real `valkey` container over the internal `redis://valkey:6379/0` DNS
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.d/0.85.0-valkey-activity-outbox.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# 0.85.0 — Fail-closed Valkey activity outbox

## Added

- Home Outbox panel lists durable ticket deliveries through
`ValkeyOutboxClient` (ADR 0026). After login with the port disabled
or Valkey down, Demo Analyst sees **Outbox · Valkey not available**.
An accepted delivery lists the summary; click opens that post. A
hidden post is omitted. Never invent a stream id or a theta.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable changes to this project are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.85.0] - 2026-08-17

### Added

- Home Outbox panel lists durable ticket deliveries through
`ValkeyOutboxClient` (ADR 0026). After login with the port disabled
or Valkey down, Demo Analyst sees **Outbox · Valkey not available**.
An accepted delivery lists the summary; click opens that post. A
hidden post is omitted. Never invent a stream id or a theta.

## [0.75.0] - 2026-08-17

### Added
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,8 @@ no ORM, no file DB) and to Keycloak's live JWKS for OIDC verification:
make up
make seed # scripts/seed_demo_data.py: inserts synthetic corp/account/post
# rows keyed to the *real* Keycloak demo users' subject ids,
# plus Valkey ticket_created events so Activity is not empty
# plus Valkey ticket_created events and activity_outbox_event
# rows so Activity and home Outbox are not empty
curl http://localhost:18420/healthz
```

Expand Down
8 changes: 8 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ class Settings:
# RankWeaveNotAvailable -- never invent a fused score. Default false
# uses the in-process library already required by reconstruct.py.
rankweave_disabled: bool
# Valkey activity outbox (ADR 0026). True = fail-closed
# ValkeyNotAvailable -- never invent a delivery. Default false
# uses the compose Valkey already required by activity_stream.
valkey_disabled: bool

@property
def keycloak_jwks_uri(self) -> str:
Expand Down Expand Up @@ -88,4 +92,8 @@ def load_settings() -> Settings:
.strip()
.lower()
in {"1", "true", "yes", "on"},
valkey_disabled=os.environ.get("VALKEY_DISABLED", "")
.strip()
.lower()
in {"1", "true", "yes", "on"},
)
86 changes: 83 additions & 3 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
from lineageweave.post_summary import ContextualOrchestratorPostSummaryClient, NullPostSummaryClient
from lineageweave.relation_verification import NullRelationVerificationClient, SearxngRelationVerificationClient
from lineageweave.rankweave_client import build_rankweave_client
from lineageweave.valkey_outbox import ValkeyOutboxClient, build_valkey_outbox_client

from backend.app.activity_stream import (
create_valkey_client,
Expand All @@ -74,6 +75,11 @@
)
from backend.app.post_evaluation_ingestion import fetch_post_evaluation, ingest_post_evaluation
from backend.app.ranking_ingestion import load_visible_ranking_posts
from backend.app.outbox_ingestion import (
load_visible_outbox_rows,
mark_outbox_delivered,
persist_pending_outbox_event,
)
from backend.app.report_ingestion import (
GROUPING_KINDS,
fetch_period_comparison,
Expand Down Expand Up @@ -242,6 +248,46 @@ def _rankweave_client():
return build_rankweave_client(disabled=load_settings().rankweave_disabled)


def _valkey_outbox_client(*, reachable: bool) -> ValkeyOutboxClient:
"""Fail-closed unless Valkey answered this request (ADR 0026)."""
if load_settings().valkey_disabled or not reachable:
return build_valkey_outbox_client(disabled=True)
return ValkeyOutboxClient(ping=lambda: None)


async def _publish_outbox_activity(
pool: asyncpg.Pool,
valkey: redis.Redis,
post_id: str,
event_type_code: str,
actor_account_id: str,
event_summary: str,
issue_ticket_id: str | None = None,
) -> None:
"""Persist pending, XADD, then mark delivered. Never invent an entry id."""
async with pool.acquire() as conn:
outbox_event_id = await persist_pending_outbox_event(
conn,
post_id,
event_type_code,
actor_account_id,
event_summary,
issue_ticket_id=issue_ticket_id,
)
if load_settings().valkey_disabled:
return
entry_id = await publish_activity_event(
valkey,
post_id,
event_type_code,
actor_account_id,
event_summary,
)
async with pool.acquire() as conn:
await mark_outbox_delivered(conn, outbox_event_id, str(entry_id))



def _can_see_post(account: CurrentAccount, post: asyncpg.Record) -> bool:
"""ABAC: public rows are visible; private rows require same-corp affiliation."""
if post["visibility_code"] == "public":
Expand Down Expand Up @@ -971,12 +1017,14 @@ async def create_post_ticket(
status.HTTP_422_UNPROCESSABLE_CONTENT,
f"due_date {request.due_date!r} is not a valid YYYY-MM-DD date",
) from exc
await publish_activity_event(
await _publish_outbox_activity(
pool,
valkey,
post_id,
"ticket_created",
account.user_account_id,
ticket_created_summary(request.ticket_title),
issue_ticket_id=str(ticket["issue_ticket_id"]),
)
return ticket

Expand Down Expand Up @@ -1029,14 +1077,16 @@ async def patch_ticket(
if ticket is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "ticket not found")
if request.ticket_status_code is not None:
await publish_activity_event(
await _publish_outbox_activity(
pool,
valkey,
post_id,
"ticket_status_changed",
account.user_account_id,
ticket_status_changed_summary(
ticket.get("ticket_status_label") or request.ticket_status_code
),
issue_ticket_id=issue_ticket_id,
)
return ticket

Expand Down Expand Up @@ -1102,12 +1152,14 @@ async def derive_post_commitment(
status.HTTP_422_UNPROCESSABLE_CONTENT,
f"due_date {commitment.due_date!r} is not a valid YYYY-MM-DD date",
) from exc
await publish_activity_event(
await _publish_outbox_activity(
pool,
valkey,
post_id,
"commitment_derived",
account.user_account_id,
f"Commitment derived: {commitment.commitment_summary}",
issue_ticket_id=str(ticket["issue_ticket_id"]),
)
return {"post_id": str(post["post_id"]), "has_commitment": True, "ticket": ticket}

Expand Down Expand Up @@ -1150,3 +1202,31 @@ async def read_rankings(
return _rankweave_client().as_api_payload(
posts, can_see_post=lambda _row: True
)


@app.get("/api/outbox")
async def read_outbox(
account: CurrentAccount = Depends(get_current_account),
pool: asyncpg.Pool = Depends(get_pool),
valkey: redis.Redis = Depends(get_valkey),
) -> dict[str, Any]:
"""Durable ticket deliveries (ADR 0026).

Hidden posts are omitted. Never invents a stream id or a theta.
Fail-closed when Valkey is disabled or does not answer.
"""
_require_post_read(account)
reachable = False
if not load_settings().valkey_disabled:
try:
await valkey.ping()
reachable = True
except Exception:
reachable = False
async with pool.acquire() as conn:
rows = await load_visible_outbox_rows(
conn, lambda row: _can_see_post(account, row)
)
return _valkey_outbox_client(reachable=reachable).as_api_payload(
rows, can_see_post=lambda _row: True
)
Loading
Loading