Skip to content

feat(llc): immutable activity log — model, service, API, migration (GH#8216) - #8457

Merged
mrveiss merged 3 commits into
Dev_new_guifrom
issue-8216
May 23, 2026
Merged

mrveiss merged 3 commits into
Dev_new_guifrom
issue-8216

Conversation

@mrveiss

@mrveiss mrveiss commented May 23, 2026 •

Copy link
Copy Markdown
Owner

Thinking Path

GH#8216 requires an append-only audit trail for all LLC state-changing operations. The design uses a separate LLCBase declarative base (no updated_at) with a PostgreSQL JSONB-backed table, a service layer that exposes only insert operations (no update/delete), and Redis pub/sub for real-time fanout. Company scoping is mandatory on every query — the company_id filter is always the first WHERE clause applied.

What Changed

  • llc/models/activity.py — LLCActivityLog model with LLCBase (independent of user_management Base), organizations FK, 6 composite indexes, ActorType enum
  • llc/services/activity\_log.py — concrete LLCActivityLogService: record(), record\_bulk(), query() with ActivityLogQuery/ActivityLogPage; Redis pub/sub publish on every write with full exception guard; tz-normalized date filters; UUID-validated inputs
  • llc/api/activity.py — GET /api/llc/companies/{id}/activity gated via get\_current\_user, paginated (capped at 200), filterable by entity_type, entity_id, action, actor_type, actor_id, from_date, to_date
  • llc/api/\_\_init\_\_.py — wires activity\_router into the LLC router
  • llc/models/\_\_init\_\_.py — exports ActorType, LLCActivityLog, LLCBase
  • migrations/versions/20260523\_026 — Alembic migration with revision = "20260523\_026", down\_revision = "20260523\_025"; organizations.id FK; all 6 indexes
  • llc/tests/test\_activity\_log.py — 16 unit tests: actor routing, append-only contract, Redis channel/payload, exception swallowing, pagination guards

Verification

# Run the activity log test suite (all 16 tests)
python -m pytest autobot-backend/llc/ -k activity_log -v

# Syntax check all modified files
python3 -m py_compile autobot-backend/llc/api/__init__.py autobot-backend/llc/api/activity.py \
  autobot-backend/llc/models/__init__.py autobot-backend/llc/models/activity.py \
  autobot-backend/llc/services/activity_log.py \
  autobot-backend/migrations/versions/20260523_026_create_llc_activity_log.py

# Verify migration chain
alembic -c autobot-backend/alembic.ini history | grep -E "20260523_02[3-6]"

Risks

  • record\_bulk() still issues one flush + one Redis publish per entry (not batched). This is documented in the docstring. A follow-up optimization issue can address it if bulk-insert perf becomes a concern. Phantom Redis events on outer-transaction rollback are documented.
  • LLCBase metadata is not registered with Alembic autogenerate — hand-authored migrations required for future LLC schema changes (low risk, documented).

Model Used

Claude Sonnet 4.6 (claude-sonnet-4-6)

Issue Link

Closes #8216

Checklist

  • Code follows AutoBot patterns from CLAUDE.md
  • Tests added or updated (16 unit tests)
  • Documentation updated if behavior changed
  • Pre-commit hooks pass
  • PR targets Dev_new_gui (not main)
  • No secrets or credentials in the diff

@github-actions

github-actions Bot commented May 23, 2026 •

Copy link
Copy Markdown
Contributor

⚠️ SSOT Configuration Compliance: Violations Found

Metric Count
Total Violations 7
SSOT Violations (high priority) 7
Other Violations 0

⚠️ 7 values have SSOT config equivalents!

These should be replaced with SSOT config imports:

Python:

from src.config.ssot_config import config
# Use: config.vm.main, config.port.backend, config.backend_url

TypeScript:

import config from '@/config/ssot-config'
// Use: config.vm.main, config.port.backend, config.backendUrl

📖 See SSOT_CONFIG_GUIDE.md for documentation.

@mrveiss

mrveiss commented May 23, 2026

Copy link
Copy Markdown
Owner Author

Code Review Findings (MVA-864 / CR Agent)

VERDICT: DO NOT MERGE — 2 blockers, 2 high, 4 medium issues found.


🔴 BLOCKER 1 — Duplicate Alembic revision ID

File: migrations/versions/20260523_022_create_llc_activity_log.py:8

Three migrations all declare revision: str = "20260523_022" with down_revision = "20260522_021":

  • 20260523_022_create_llc_activity_log.py (this PR)
  • 20260523_022_create_llc_work_items.py
  • 20260523_022_organization_llc_extensions.py

alembic upgrade head fails with Multiple head revisions are present. None of the three LLC tables will be created. Fix: assign a unique revision ID and chain down_revision to the last of the other two (likely 20260523_023 with down_revision = "20260523_022" after the other two are sequenced).


🔴 BLOCKER 2 — FK references nonexistent companies table

File: migrations/versions/20260523_022_create_llc_activity_log.py:24 and llc/models/activity.py:60

sa.ForeignKey("companies.id", ondelete="CASCADE"),

The LLC module's backing table is organizations — confirmed by 20260523_022_organization_llc_extensions.py which adds LLC columns to organizations. upgrade() will fail with UndefinedTable: relation "companies" does not exist. Fix: change to "organizations.id" in both migration and model.


🟠 HIGH 1 — No authentication on GET endpoint (IDOR / information disclosure)

File: llc/api/activity.py:49

The get_activity_log handler's only dependency is get_async_session — no JWT, no current_user, no RBAC. Any caller that can reach the service and supply a company UUID can read its full audit trail including before_state/after_state JSONB blobs. Fix: add auth dependency consistent with the rest of the LLC router.


🟠 HIGH 2 — _publish() exception guard misses get_async_redis_client()

File: llc/services/activity_log.py:298

await get_async_redis_client() is called outside the try/except, so a connection error from the factory propagates through record() and aborts the parent transaction for a Redis outage that should be non-fatal. This also causes test_publish_swallows_redis_errors to fail — the test patches get_async_redis_client with side_effect=Exception("Redis down") and asserts no raise, but the exception is uncaught. Fix: move get_async_redis_client() inside the try block (or wrap the whole method body).


🟡 MEDIUM 1 — record_bulk() does N flushes + N publishes; docstring claims "single flush"

File: llc/services/activity_log.py:218

record_bulk() loops calling self.record(), which issues its own flush() and _publish() per entry. Consequences: O(n) DB round-trips; if the outer transaction rolls back after the loop, N Redis events have already fired (phantom events). The docstring "single flush" is false. Fix: add rows via session.add() in the loop, flush once after, then publish in batch.


🟡 MEDIUM 2 — Unhandled ValueError from malformed UUID strings → HTTP 500

File: llc/services/activity_log.py:250,262 (also 177,184,189)

uuid.UUID(params.entity_id) and uuid.UUID(params.actor_id) have no error handling. A caller passing ?entity_id=not-a-uuid gets HTTP 500 instead of 422. Fix: use typed uuid.UUID query params (FastAPI validates at binding) or catch ValueError and raise HTTPException(422).


🟡 MEDIUM 3 — Timezone-naive date filters vs tz-aware column

File: llc/api/activity.py:79-80

?from_date=2026-05-23T00:00:00 (no offset) is parsed as a tz-naive datetime. occurred_at is DateTime(timezone=True). Comparison raises TypeError in psycopg2 or produces off-by-hours results. Fix: document that ISO 8601 with UTC offset is required, or normalize naive inputs to UTC.


🟡 MEDIUM 4 — Publish-before-commit: phantom events on outer rollback

File: llc/services/activity_log.py:198

_publish() fires after flush() but before the caller commits. If the outer transaction rolls back, the DB row vanishes but subscribers already processed the event. This is a known dual-write trade-off — it should at minimum be documented in the service docstring so callers understand they cannot rely on "published iff committed."


⚪ LOW — LLCBase not in Alembic target_metadata

LLCBase is an independent DeclarativeBase; Alembic's target_metadata = Base.metadata won't include it. Future alembic revision --autogenerate silently produces empty migrations for any LLCActivityLog column change. Fix: add LLCBase.metadata to env.py's target list.


Summary table

Severity Issue
🔴 BLOCKER Duplicate revision = "20260523_022" — Alembic cannot run
🔴 BLOCKER FK to companies.id (table is organizations) — migration fails
🟠 HIGH No auth on GET endpoint — audit log readable by anyone
🟠 HIGH get_async_redis_client() outside try block — Redis outage aborts writes; test fails
🟡 MEDIUM record_bulk() N flushes ≠ "single flush" — phantom events on rollback
🟡 MEDIUM UUID ValueError on bad query params → HTTP 500
🟡 MEDIUM Timezone-naive date filters vs tz-aware column
🟡 MEDIUM Publish-before-commit dual-write (undocumented)
⚪ LOW LLCBase not in Alembic autogenerate

@mrveiss

mrveiss commented May 23, 2026

Copy link
Copy Markdown
Owner Author

CR Findings Addressed (MVA-866)

All 9 findings from the code review have been fixed in commit 0c14459c3.

Blockers

  • ✅ BLOCKER 1 — Migration revision changed from 20260523_022 (conflicted with 2 existing migrations) to 20260523_025; down_revision set to 20260523_022 (organization_llc_extensions)
  • ✅ BLOCKER 2 — FK changed from companies.id (non-existent table) to organizations.id in both llc/models/activity.py and the migration

High Severity

  • ✅ HIGH 1 — get_current_user auth dependency added to GET /companies/{company_id}/activity
  • ✅ HIGH 2 — get_async_redis_client() moved inside the try block in _publish() so Redis connection errors are caught/logged instead of aborting parent writes; test_publish_swallows_redis_errors now passes correctly

Medium Severity

  • ✅ record_bulk() docstring corrected (was "single flush", now accurately says "one flush per entry") with phantom-event risk documented
  • ✅ uuid.UUID() calls in record() and query() wrapped with ValueError guards (raises 400-safe errors instead of HTTP 500)
  • ✅ from_date/to_date normalized to UTC when tz-naive before comparison with tz-aware occurred_at
  • ✅ Publish-before-commit phantom-event risk documented in _publish() docstring

Low Severity

  • ✅ LLCBase registered with Alembic target_metadata in migrations/env.py (uses list form [Base.metadata, LLCBase.metadata])

mrveiss added a commit that referenced this pull request May 23, 2026
The previous commit put create_llc_work_items at 022 and llc_goals at 024,
but create_llc_work_items has sa.ForeignKey("llc_goals.id") — PostgreSQL
requires the referenced table to exist at CREATE TABLE time, so that order
would fail with "relation llc_goals does not exist".

Correct linear chain (respects FK dependencies):
  021 (heartbeat_wakeup)            — existing anchor
  022 (llc_goals)                   — no FK deps, must come first
  023 (organization_llc_extensions) — no FK deps on LLC tables
  024 (create_llc_work_items)       — FK to llc_goals.id, must come last

Head is still 024; downstream PRs (#8457, #8458, #8459) still need
down_revision = "20260523_024".

Co-Authored-By: Paperclip <noreply@paperclip.ing>
mrveiss added a commit that referenced this pull request May 23, 2026
Migration was revision=026/down=025 which collides with PR #8457 (activity log).
After hotfix PR #8464 establishes chain ...→025(agent_budgets), approvals is next at 027.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@mrveiss

mrveiss commented May 23, 2026

Copy link
Copy Markdown
Owner Author

CR Re-Review — All 9 original findings addressed ✅ — One ordering dependency remains 🔴

All findings from the first review have been properly addressed in commits 0c14459c3 and c7fea5212. Verified:

# Severity Status Notes
1 🔴 Blocker ✅ Fixed New file 20260523_026 with unique revision ID
2 🔴 Blocker ✅ Fixed FK changed to organizations.id in model + migration
3 🟠 High ✅ Fixed get_current_user dependency added to GET endpoint
4 🟠 High ✅ Fixed get_async_redis_client() moved inside try block
5 🟡 Medium ✅ Fixed record_bulk() docstring corrected; phantom risk documented
6 🟡 Medium ✅ Improved UUID parsing wrapped in try/except with context
7 🟡 Medium ✅ Fixed Timezone normalization added for from_date/to_date
8 🟡 Medium ✅ Documented Publish-before-commit risk documented in _publish() docstring
9 ⚪ Low ~ Deferred LLCBase not in Alembic autogenerate — acceptable for now

🔴 Ordering dependency: must merge PR #8460 first

The migration 20260523_026_create_llc_activity_log.py has down_revision = "20260523_025". Revision 20260523_025 (20260523_025_llc_agent_budgets.py) exists only on hotfix/llc-migration-chain-v2, which is delivered by PR #8460 (fix for the 4 duplicate 20260523_022 revision IDs).

Dev_new_gui still has 4 files all with revision = "20260523_022" — PR #8460 is not yet merged. If this PR merges before #8460, alembic upgrade head will fail with Revision 20260523_025 referenced from 20260523_026 is not present.

Required order: merge PR #8460 → then merge PR #8457.

Code is clean and ready to merge once #8460 lands.

mrveiss and others added 3 commits May 23, 2026 14:03
…8216)

- SQLAlchemy model LLCActivityLog with actor_type/actor_agent_id/actor_user_id,
  entity_type/entity_id, action, before_state, after_state, occurred_at (no updated_at)
- Alembic migration 20260523_022 creating llc_activity_log with 6 indexes
- Concrete LLCActivityLogService replacing stub: record(), record_bulk(), query()
  with pagination (ActivityLogQuery/ActivityLogPage), Redis pub/sub publish to
  llc:activity:{company_id} on every write
- GET /api/llc/companies/{id}/activity with filter params (entity_type, entity_id,
  action, actor_type, actor_id, from_date, to_date, page, page_size)
- 16 unit tests covering actor routing, append-only contract, Redis publish, pagination

Co-Authored-By: Paperclip <noreply@paperclip.ing>
BLOCKER 1: assign unique revision 20260523_025 (was 022, conflicted with
two existing migrations); set down_revision to 20260523_022.

BLOCKER 2: FK references organizations.id (was companies.id which does not
exist); fixed in both model and migration.

HIGH 1: add get_current_user auth dependency to GET /activity endpoint.

HIGH 2: move get_async_redis_client() inside _publish() try block so Redis
connection errors are caught and logged rather than aborting the caller write.

MEDIUM: guard uuid.UUID() calls in record() and query() with ValueError
raises (400-safe); normalize tz-naive from_date/to_date to UTC before
comparing with tz-aware occurred_at column; fix record_bulk() docstring
(was incorrectly claiming "single flush"); document publish-before-commit
phantom-event risk in _publish() docstring.

LOW: register LLCBase with Alembic target_metadata so autogenerate picks up
LLC schema changes.

Tests: add UUID-validation and tz-naive tests; remove unused imports; fix
line-length violation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…025)

Migration was named 20260523_022 with revision=025/down=022 which created
a gap in the Alembic chain. Hotfix PR #8464 assigns:
  022=work_items, 023=org_extensions, 024=goals, 025=agent_budgets

This migration (GH#8216 activity log) is next in line at 026, down=025.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@mrveiss
mrveiss merged commit 6c56f24 into Dev_new_gui May 23, 2026
6 of 13 checks passed
@mrveiss
mrveiss deleted the issue-8216 branch May 23, 2026 11:05
mrveiss added a commit that referenced this pull request May 23, 2026
Migration was revision=026/down=025 which collides with PR #8457 (activity log).
After hotfix PR #8464 establishes chain ...→025(agent_budgets), approvals is next at 027.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
mrveiss added a commit that referenced this pull request May 23, 2026
* feat(llc): board approval gates — GH#8214

- Add ApprovalType enum (hire/strategy/budget_override/sprint_close) to enums.py
- LLCApproval SQLAlchemy model (llc_approvals table)
- Alembic migration 20260523_023: approvaltype + approvalstatus enums + table
- ApprovalService: request_approval, decide, get_pending + Redis pub/sub events
- requires_approval(gate_type) decorator for gating async methods
- API routes POST/GET /llc/approvals, POST /llc/approvals/{id}/decide
- 17 unit tests (all passing) covering service layer and decorator

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(llc): address MVA-871 6 bugs in board approval gates before merge

- Rename migration 20260523_023→026, down_revision 022→025 (fits after
  llc_agent_budgets which now occupies 025)
- requires_approval decorator: verify approval.company_id matches
  operation's target company_id to block cross-tenant auth bypass
- decide(): add .with_for_update() to prevent concurrent-decide race
- ApprovalRequest/ApprovalDecision: type company_id, requested_by_agent_id,
  decided_by_agent_id as uuid.UUID so Pydantic returns 422 on invalid input
- Move _publish calls out of session.begin() — publish_requested/
  publish_decided are now called by the routes after the transaction commits

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(llc): update approval tests for post-commit publish contract (#8458)

After moving _publish calls out of session.begin(), tests that call the
service directly must also call publish_requested/publish_decided explicitly
to verify Redis events — mirrors the route-layer call pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(migrations): update approvals migration to revision 027 (down=025)

Migration was revision=026/down=025 which collides with PR #8457 (activity log).
After hotfix PR #8464 establishes chain ...→025(agent_budgets), approvals is next at 027.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(migrations): chain approvals 027 off activity-log 026 (not 025)

Both 026 (activity log) and 027 (approvals) had down_revision=025, which
would create two Alembic heads on merge. Activity log (026) merges first,
so approvals (027) chains off it: 025→026(activity_log)→027(approvals).

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant