Skip to content

feat(team-server): Priority C v0+v1+v1.1 (clean dev-rebased reduplication of #153)#181

Merged
Knapp-Kevin merged 26 commits into
devfrom
claude/priority-c-team-server-fresh
May 5, 2026
Merged

feat(team-server): Priority C v0+v1+v1.1 (clean dev-rebased reduplication of #153)#181
Knapp-Kevin merged 26 commits into
devfrom
claude/priority-c-team-server-fresh

Conversation

@Knapp-Kevin

Copy link
Copy Markdown
Collaborator

Summary

Fresh PR carrying the same payload as #153 (Priority C v0 + v1 + v1.1 — self-managing team-server, Slack + Notion ingest, heuristic + LLM extractor, channel_allowlist sync, team-server event consumer + materializer dispatch) but cherry-picked onto current dev to avoid the architectural drift in #153 that was blocking merge.

Why a fresh PR: PR #153's branch had events/session_end_bridge.py and an older Step 5.6 in bicameral-preflight/SKILL.md that conflict with what landed on dev since (#175's Step 5.6.1/5.6.2 rework + the bicameral-capture-corrections approach). #153 also had a stuck CI trigger after #159 (its main-targeting duplicate) was closed mid-push; new SHAs on the branch wouldn't fire workflows. This PR side-steps both problems by branching off latest dev and carrying only the non-drifting team-server commits.

Linked issues

Closes #161 (channel_allowlist startup-time YAML sync)
Closes #160 (team-server event consumer + materializer dispatch)
Refs #153 (the original PR being reduplicated; this PR supersedes it)

What was carried over

Commits 1-22 of #153's branch (oldest to newest):

  • feat(team-server): scaffold + self-managing schema (Phase 1)
  • feat(team-server): Slack OAuth + workspace allow-list (Phase 2)
  • feat(team-server): Slack worker + canonical-extraction cache (Phase 3)
  • feat(team-server): HTTP /events API + materializer extension (Phase 4)
  • refactor(team-server): cache-contract migration to upsert-per-source_ref (Phase 0)
  • feat(team-server): worker-task lifecycle pattern + Slack reference wiring (Phase 0.5)
  • feat(team-server): Notion API client + property serializer (Phase 1)
  • feat(team-server): Notion ingest worker + per-database watermark (Phase 2)
  • feat(team-server): Notion task registration on lifespan (Phase 3)
  • refactor(team-server): cache contract gets classifier_version axis (Phase 0)
  • feat(team-server): heuristic classifier — pure deterministic Stage 1 (Phase 1)
  • feat(team-server): trigger rules schema + per-channel/db merge (Phase 2)
  • feat(team-server): real LLM extractor via Anthropic SDK (Phase 3)
  • feat(team-server): pipeline integration — workers route Stage 1 → Stage 2 (Phase 4)
  • feat(team-server): corpus learner — option-c feedback loop (Phase 5)
  • feat(team-server): channel_allowlist startup-time YAML sync (closes #161)
  • feat(team-server): periodic team-server event consumer + payload bridge (closes #160 first half)
  • feat(team-server): materializer dispatch case for team-server JSONL events (closes #160 second half)
  • All v0/v1/v1.1 governance plan/audit/seal docs

Plus a fix(team-server): mypy + ruff for fresh dev-based branch commit on top with:

  • _one_attempt return-type tightening + isinstance assert in llm_extractor.py
  • _interim_extractor as adapter function in app.py (matches worker Extractor protocol)
  • ruff auto-fix + format pass for 44 files now ruff-clean against current dev config

What was deliberately left behind

Commits 23-27 from #153 (architecturally drifted from current dev):

Plan / Audit / Seal

Test plan

  • mypy . — 131 files, no issues
  • ruff check . — All checks passed
  • ruff format --check . — 278 files already formatted
  • pytest tests/test_team_server_app.py tests/test_team_server_allowlist_lifespan.py tests/test_team_server_allowlist_sync.py tests/test_team_server_consumer.py tests/test_materializer_team_server_pull.py — 28 passed
  • CI re-trigger validation — primary purpose of opening as a fresh PR; if CI fires here where it didn't on Priority C v0 — self-managing team-server, Slack-first ingest #153, that confirms the trigger drop was PR-state-related

Notes for reviewer

Knapp-Kevin and others added 24 commits May 2, 2026 23:27
Priority C v0 — Phase 1 of the team-server vertical-slice for multi-dev
decision continuity at organizational scale (per the Sales Enablement &
Positioning Playbook + research-brief-priority-c-selective-ingest-2026-05-02.md).

The team-server is a self-managing, customer-self-hosted Python service.
Per CONCEPT.md anti-goals under literal-keyword parsing (SHADOW_GENOME
Failure Entry #6 addendum): "no managed backend" forbids vendor SaaS
and human-ops-tax architectures, NOT self-managing customer-deployable
backends. Sentry self-hosted, Supabase OSS, and the embedded-SurrealDB
philosophy already in repo are precedents.

Scaffold delivered:
- team_server/app.py (47 LOC): FastAPI app factory; lifespan migrates
  schema on startup, closes DB on teardown; /health endpoint
- team_server/schema.py (80 LOC): v0 schema for workspace,
  channel_allowlist, extraction_cache, team_event tables. Idempotent
  ensure_schema(); migration dispatch table for future versions.
  FLEXIBLE TYPE object on canonical_extraction + payload (per #72
  lesson + audit Advisory #3).
- team_server/db.py (41 LOC): TeamServerDB factory wrapping
  ledger.client.LedgerClient with team-server's own ns/db pair.
- deploy/team-server.docker-compose.yml + Dockerfile.team-server:
  single-service compose; volume for persistent data; healthcheck on
  /health; runs as non-root.

Tests (6 functionality tests, all green):
- tests/test_team_server_app.py: app starts + serves health, schema
  migrates from empty + idempotent, lifespan teardown closes DB,
  /health returns well-formed JSON.
- tests/test_team_server_deploy.py: docker-compose config validates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 of plan-priority-c-team-server-slack-v0.md — adds Slack OAuth v2
flow, workspace persistence with at-rest encrypted tokens, and a
schema-validated channel allow-list config loader.

Per audit Advisory #2, OAuth routes are factored into
team_server/auth/router.py (parallel to Phase 4's events router pattern)
rather than left inline in app.py — keeps app.py at 47 lines well under
the Section 4 razor cap.

Files added:
- team_server/auth/slack_oauth.py (58 LOC): pure functions —
  build_authorize_url, exchange_code; raises SlackOAuthError on ok=false.
- team_server/auth/encryption.py: Fernet encrypt/decrypt for OAuth tokens
  at rest; key loaded from BICAMERAL_TEAM_SERVER_SECRET_KEY env var.
- team_server/auth/router.py (73 LOC): /oauth/slack/install +
  /oauth/slack/callback routes with CSRF state defense; persists
  workspace row with token encrypted before storage.
- team_server/config.py (40 LOC): pydantic-validated YAML loader for
  channel allow-list; raises ValueError with descriptive message on
  schema failure.
- team_server/app.py (modified): include auth router.

Tests (7 functionality tests, all green):
- tests/test_team_server_slack_oauth.py: authorize URL contains required
  params + scopes, exchange_code POSTs correctly, encrypt/decrypt
  round-trip preserves plaintext while ciphertext differs, callback
  rejects mismatched state (CSRF defense), callback persists workspace.
- tests/test_team_server_channel_allowlist.py: load_channel_allowlist
  parses valid YAML, raises ValueError on missing team_id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3 of plan-priority-c-team-server-slack-v0.md — adds the polling
worker, the canonical-extraction cache that closes the multi-dev
extraction-divergence gap, and the peer-author event writer.

Multi-dev convergence mechanism: any dev's session that touches a Slack
message produces the SAME canonical extraction across the team because
get_or_compute is keyed on (source_type, source_ref, content_hash) and
the cache row is shared via the team-server's append-only event log.
This is what closes Playbook Pillar #1 (Decision Continuity) at
multi-dev/multi-agent scale that the v1 brief incorrectly framed as
"build curation only" — see SHADOW_GENOME Failure Entry #6.

The interim canonical extraction uses Anthropic Claude with the
model_version='interim-claude-v1' tombstone so a future Phase 5 (CocoIndex
#136 integration) can identify and rebuild interim entries when memoized
deterministic transforms become available.

Files added:
- team_server/extraction/canonical_cache.py (45 LOC): get_or_compute()
  returns cached extraction OR invokes compute_fn + persists.
- team_server/extraction/llm_extractor.py: interim Anthropic-backed
  extractor; production-default until CocoIndex lands.
- team_server/sync/peer_writer.py (42 LOC): write_team_event() — appends
  to team_event with author_email='team-server@<team_id>.bicameral'
  identity (single-bot per workspace; multi-instance HA is v1).
- team_server/workers/slack_worker.py (100 LOC): poll_once() —
  conversations_history per allowlisted channel, content_hash dedup,
  cache-keyed extraction, peer event write per new message.

Tests (6 functionality tests, all green):
- tests/test_team_server_canonical_cache.py: cache hit returns existing
  without compute_fn, cache miss persists + subsequent call returns
  cached, content_hash variation produces new rows.
- tests/test_team_server_slack_worker.py: worker polls only allowlisted
  channels, writes one team_event per message with peer-author identity,
  dedups via message ts (idempotent re-poll).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4 of plan-priority-c-team-server-slack-v0.md — closes the
multi-dev convergence loop by exposing team_event over HTTP and
extending EventMaterializer with a failure-isolated team-server pull.

The materializer pull is OUTSIDE the deterministic core (per CONCEPT.md
literal-keyword parsing of "no network calls in the deterministic core"
— SHADOW_GENOME Failure Entry #6 addendum). Failure-isolation contract:
team-server outage NEVER cascades into per-dev preflight failures —
events/team_server_pull.py swallows transport errors, returns empty
events, leaves the watermark unchanged.

Files added:
- team_server/api/events.py: GET /events?since=N&limit=M endpoint;
  reads team_event ordered by sequence ascending; pagination via
  since cursor.
- events/team_server_pull.py (57 LOC): pull_team_server_events()
  queries the team-server's /events endpoint, persists watermark per
  call, swallows transport errors gracefully.
- team_server/app.py (modified): include events router.

Tests (6 functionality tests, all green):
- tests/test_team_server_events_api.py: /events returns rows in
  sequence order, paginates via since cursor, returns empty (not
  error) when no new events.
- tests/test_materializer_team_server_pull.py: materializer pulls from
  team-server URL, watermark advances + persists separately, 503
  /transport-error degrades gracefully (no exception, watermark
  unchanged) — failure-isolation contract verified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
QorLogic SDLC governance trail for the Priority C v0 implementation
that landed in commits 1-4 of this PR.

Includes:

- docs/research-brief-priority-c-selective-ingest-2026-05-02.md (v3) —
  research substrate. v1 was rejected for INVARIANT_FROM_IMPLEMENTATION
  (treating v0 agent-fetches-only code state as product principle); v2
  added playbook substrate; v3 narrowed to Slack-first + team-server
  + CocoIndex-conditional after operator dialogue clarified "no managed
  backend" = "no human-ops-tax architecture," not "no backend."
- plan-priority-c-team-server-slack-v0.md (437 LOC) — the L3 plan
  with five phases (Phase 5 deferred per "if we can manage it"
  feasibility caveat).
- docs/SHADOW_GENOME.md Failure Entry #6 + addendum — captures the
  framing-error pattern AND the "anti-goals must be parsed by their
  load-bearing keyword" lesson; symmetric to v0-code-as-principle.
- docs/META_LEDGER.md Entries #27 (IMPLEMENT) + #28 (SEAL).
  Predecessor: efd0304b (#135-triage seal on dev).
  Implement chain: 211ffb9e.
  Substantiation seal: 6f4f8f8f1d63ad82b952a3c6aff270d30584e08b0572077ff685e84ce453f6c2
- docs/SYSTEM_STATE.md — Priority C v0 section appended; documents
  schema additions, architectural properties achieved, audit advisory
  disposition, Phase 5 deferred state, and the qor-logic-internal
  steps skipped for downstream-project rationale.
- .agent/staging/AUDIT_REPORT.md — PASS verdict, three non-blocking
  advisories all addressed at implement-time.

Verdict: REALITY = PROMISE for Phases 1-4. Phase 5 (CocoIndex #136)
explicitly deferred per plan slip-independence design.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ref + schema_version (Phase 0)

Schema v1->v2: extraction_cache.UNIQUE keyed on (source_type, source_ref);
content_hash becomes a tracked column. canonical_cache.get_or_compute()
replaced by upsert_canonical_extraction(...) -> tuple[dict, bool] returning
(extraction, changed). Slack worker adapts to the new contract; gates the
team_event write on the returned changed flag (idempotent on no-content
change). _MIGRATIONS dispatch upgraded to Callable[[LedgerClient],
Awaitable[None]]. New schema_version single-row table records the post-
migration version as data, not folklore. _migrate_v1_to_v2 dedups duplicate
(source_type, source_ref) rows by max(created_at) before redefining the
v2 index.

Tests: 12 functionality tests across cache_upsert, schema_migration, and
slack_worker adaptation; canonical_cache test rewritten under v2 upsert
contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ring (Phase 0.5)

Establishes the worker-task lifecycle pattern via worker_loop(name,
interval_seconds, work_fn) — single source of truth for the
asyncio.create_task / per-iteration error isolation / cancel-on-shutdown
shape. slack_runner.run_slack_iteration is the canonical reference
implementation: iterates the workspace table, decrypts each Fernet token
via load_key_from_env() + decrypt_token(ciphertext, key), reads the
channel allowlist, constructs an AsyncWebClient, and delegates one
polling pass to slack_worker.poll_once. Per-workspace exceptions are
caught for failure isolation.

app.py lifespan registers the Slack worker unconditionally (no-op when
workspace table is empty); the registered task is cancelled and awaited
on shutdown. Closes the v0 dormant-Slack-worker gap: v0 plan claimed an
active worker but v0 code shipped poll_once with zero production callers.

Tests: 7 functionality tests including the round-trip encryption test
(encrypt -> store-as-string -> read-as-bytes -> decrypt -> token reaches
slack_client) that closes the audit-round-2 blind spot per
SHADOW_GENOME #7 addendum.

slack_sdk import in slack_runner is lazy (inside run_slack_iteration) so
the team_server package is importable in environments where slack_sdk
is declared in requirements.txt but not in dev venv.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
team_server/auth/notion_client.py provides internal-integration-token
auth (no OAuth router): load_token resolves NOTION_TOKEN env first,
falling back to YAML config's notion.token; raises NotionAuthError if
neither is set. Pure async functions over httpx.AsyncClient with
Notion-Version pinned to 2022-06-28: list_databases (filtered to
object=database), query_database (per-database last_edited_time
watermark filter, ascending sort, paginated), fetch_page_blocks
(paginated children).

team_server/extraction/notion_serializer.py serializes a Notion
database row deterministically: title line, then sorted-by-key property
lines (title/rich_text/select/multi_select/date/checkbox/number/url/
people branches), then a blank line, then body block plain-text. Byte-
stable output is the gating invariant for content_hash stability.

team_server/config.py: DEFAULT_CONFIG_PATH constant with
BICAMERAL_CONFIG_PATH env-var fallback; Path-typed.

Tests: 7 client tests (env-vs-config precedence, MockTransport
verification of filter shapes + Notion-Version header pinning + block
pagination), 3 serializer tests (ordering, all property-type branches,
byte-stability across calls).

No new package dependencies — httpx and yaml already in v0 deps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…se 2)

team_server/workers/notion_worker.py polls allowlist-via-share Notion
databases (the integration sees only databases the operator has shared
with it — derived dynamically from notion_client.list_databases, no
separate allowlist table required). Per-database watermark stored in
the new source_watermark table, advanced monotonically as rows
ingest. Partial-failure recovery: watermark advances only to the last
successfully-ingested row's last_edited_time, so the next poll resumes
correctly. Per-database HTTPError is caught and logged so a single
failing database does not block other databases.

Each row's text input is the deterministic serializer output (title +
sorted properties + body); content_hash is SHA256 over that text.
upsert_canonical_extraction returns (extraction, changed); when
changed=True, a peer-authored team_event is written under
PEER_WORKSPACE_ID="notion" (resulting author_email
"team-server@notion.bicameral" via write_team_event's wrapper).
source_type="notion_database_row"; source_ref="{db_id}/{page_id}".

Tests: 9 functionality tests covering database iteration via
list_databases, first-seen-row event, idempotency on unchanged rows,
new event on edited rows, monotonic watermark advancement, watermark-
to-filter wiring, partial-failure recovery, per-database 404 isolation,
content_hash stability across dict insertion-order changes (the
serializer determinism invariant under the polling layer).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
team_server/workers/notion_runner.py: thin wrapper run_notion_iteration
over notion_worker.poll_once for symmetry with slack_runner (both
expose a zero-extra-arg work_fn for the lifespan to register via
worker_loop). Internal-integration auth means a single token covers a
single workspace; v1 ships single-workspace.

team_server/app.py lifespan amended: after Slack worker registration
(unconditional), attempts notion_client.load_token via DEFAULT_CONFIG_PATH;
on success registers a Notion task via the same worker_loop helper.
On NotionAuthError logs INFO and continues without Notion ingest.
On shutdown, both tasks are cancelled and awaited symmetrically.

Tests: 4 functionality tests covering env-gated startup wiring,
off-by-default invariant when token unset, cancellation on shutdown,
and inner-loop resilience (single-iteration failure does not exit
the loop).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three-round audit cycle (VETO -> VETO -> PASS) for Notion ingest +
cache contract migration. Plan ships across five phases:

- Phase 0 — cache contract migration (schema v1->v2, schema_version
  table, callable migration dispatch, upsert_canonical_extraction)
- Phase 0.5 — worker-task lifecycle pattern + Slack reference wiring
  (closes the v0 dormant-Slack-worker gap)
- Phase 1 — Notion API client + property serializer (internal-
  integration auth, no OAuth router)
- Phase 2 — Notion ingest worker (per-database watermark, peer-
  authored team_event)
- Phase 3 — Notion task registration on lifespan

META_LEDGER entries #29-#33 capture: round-1 VETO (4 missing/
undeclared symbols), round-2 VETO (1 wrong-call-shape for
decrypt_token), round-3 PASS, IMPLEMENT, and SUBSTANTIATION.

SHADOW_GENOME #7 addendum extends the PARALLEL_STRUCTURE_ASSUMED
detection heuristic with three new in-sketch checks: signature,
type-boundary, helper-symmetry. The two VETOs in this session are
the empirical justification.

SYSTEM_STATE.md adds the Priority C v1 section: schema state (v2),
architectural properties achieved, audit cycle outcomes,
implementation deviations from plan.

Merkle seal: SHA256(content_hash + previous_hash) =
dcb619104e6d88b97a04689093b80b9f03825f9a24bac3c3b9ab3d0107ff24d7
(content_hash 9f003c40..., previous_hash 6f4f8f8f... = Priority C v0
SEAL at Entry #28).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…hase 0)

Schema v2->v3: extraction_cache gains classifier_version field
(option<string> with DEFAULT 'legacy-pre-v3'). upsert_canonical_extraction
now requires classifier_version as keyword-only; cache hit requires
BOTH content_hash AND classifier_version match. Either differing
triggers re-extraction.

The option<string> type accommodates pre-v3 rows whose field reads
NONE before the migration's UPDATE backfills them — strict TYPE
string would reject those reads (surfaced by the v2-to-v3 backfill
integration test added per audit advisory L4-B from the QorLogic
Fixer's Layer 4 sweep).

_migrate_v2_to_v3 callable: defines the field permissively, then
unconditionally UPDATE-backfills rows where classifier_version IS
NONE. Idempotent.

Workers (slack, notion) pass classifier_version="legacy-pre-v3" until
pipeline integration (Phase 4) supplies the real heuristic version.

Tests: 14 functionality tests across Phase 0 (cache_upsert/schema
adaptations + classifier_version axis verification + v2->v3 backfill
integration test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(Phase 1)

team_server/extraction/heuristic_classifier.py provides Stage 1 of the
extraction pipeline: pure-function classify(message, context, rules)
returning ClassificationResult(is_positive, matched_triggers,
classifier_version). Deterministic by construction (no LLM, no
temperature, no time/uuid/random); rule-set hash drives downstream
cache invalidation.

Inputs: message dict (text + structural fields), context dict
(reactions, thread_position, channel/db_id), TriggerRules (operator-
configured + corpus-learned terms). The classifier honors:
- keyword positives + keyword negatives (negatives short-circuit)
- min_word_count length floor
- reaction-count boosters (option d — context-aware)
- thread-tail position booster (option d)
- learned_keywords merge (option c — populated by Phase 5)

derive_classifier_version produces a stable SHA256 hash of the
sorted rule-set; changes invalidate the upsert cache via the
classifier_version axis added in Phase 0.

Tests: 9 functionality tests covering keyword match, negative
override, length floor, reaction boost, thread-tail booster,
determinism, version-changes-on-rule-change, and unicode/emoji
robustness.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
team_server/config.py extended with pydantic models for the heuristic
trigger rules: HeuristicGlobalRules (workspace-level defaults),
HeuristicScopedOverride (per-channel/database additive overrides),
SlackHeuristics, NotionHeuristics, NotionConfig, CorpusLearnerConfig.

YAML alias 'global:' maps to global_rules field via populate_by_name=True
+ alias='global' (avoids the Python reserved-word collision).
Resolvers resolve_rules_for_slack and resolve_rules_for_notion produce
TriggerRules | RulesDisabled, merging global + scoped + learned
keywords additively. RulesDisabled is the sentinel for opted-out
channels/databases.

Backwards compatibility: load_channel_allowlist preserved as an alias
for load_rules_from_config so existing v0 OAuth callers continue to
work unchanged.

Tests: 5 functionality tests covering YAML loading, channel-override
merge, database-override merge, disabled-channel sentinel, and
ValidationError propagation as ValueError.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
team_server/extraction/llm_extractor.py: full rewrite of the v1.0
paragraph-split placeholder. extract(text, matched_triggers) async
calls the Anthropic Messages API (claude-haiku-4-5 default; selectable
via BICAMERAL_TEAM_SERVER_EXTRACT_MODEL env). Returns structured
{"decisions": [{"summary", "context_snippet"}], "extractor_version",
"matched_triggers"}.

Failure handling:
- ANTHROPIC_API_KEY unset: raises MissingAnthropicKeyError (fail-loud)
- HTTP 429: exponential backoff retry (1s, 2s; max 3 attempts)
- HTTP 5xx / network errors: fail-soft with truncated error string
- Unparseable JSON output: fail-soft with parse-failure message
- Non-text content blocks (ToolUseBlock etc.): fail-soft (closes
  Fixer L1-C from the proactive code-quality sweep)

Anthropic SDK imported lazily inside extract() so the module remains
importable when anthropic is in requirements.txt but not in dev venv
(matches the slack_sdk lazy-import pattern from v1.0 Phase 0.5).

extractor_version is a SHA256 prefix of the prompt template + model
name, so changes to either invalidate downstream cache via the
classifier_version cousin axis.

Tests: 7 functionality tests covering structured output parsing,
trigger-grounding in prompt, 429 retry, 500 fail-soft, parse-failure
fail-soft, env-overridden model, and fail-loud-on-missing-key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ge 2 (Phase 4)

team_server/extraction/pipeline.py provides the single entry point
extract_decision_pipeline(*, text, message, context, rules_or_disabled,
llm_extract_fn). Determines the output shape regardless of source:
{decisions, classifier_version, matched_triggers, extractor_version,
skipped}. extractor_version is None when Stage 2 didn't run (chatter,
rules-disabled).

slack_worker._ingest_message: builds context dict (reactions,
thread_position, thread_ts, subtype), resolves rules per channel via
config, routes through pipeline. classifier_version computed cheaply
from rules; the cache check happens BEFORE the LLM call.

notion_worker._ingest_row: builds context dict (last_edited_by,
edit_count), resolves rules per database, routes through pipeline.

Both workers preserve the legacy `extractor(text)` path when config
is None — preserves v1.0 worker tests + provides a clean cutover path
for callers that haven't adopted the rules schema.

Tests: 5 functionality tests covering pipeline short-circuit on
chatter, LLM invocation on positives, rules-disabled passthrough, and
worker-side context handoff for Slack (thread + reactions) and Notion
(edit metadata).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
team_server/extraction/corpus_learner.py reads the team-server's own
team_event log (per OQ-1: not the per-repo decision table that doesn't
exist server-side), extracts top n-grams from positive-extraction
decisions, persists to learned_heuristic_terms with operator-denylist
respected.

Schema v3->v4 adds learned_heuristic_terms table (UNIQUE on
source_type+term). Persistence is upsert-shaped: re-runs update
support_count + learned_at without duplicating rows.

resolve_rules_for_slack / resolve_rules_for_notion accept a
learned=tuple[str, ...] argument that merges into TriggerRules.
learned_keywords. The classifier already consumes this via the same
match path as operator-configured keywords.

app.py lifespan registers a corpus-learner worker via the existing
worker_loop helper when config.corpus_learner.enabled is true (default
false). Off-by-default; opt-in via YAML.

Tests: 7 functionality tests covering n-gram extraction, denylist
honor, persistence, determinism, learned-keyword merge, lifespan-
on-when-enabled, lifespan-off-when-disabled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First-round PASS audit cycle for the real heuristic+LLM extractor.
Plan ships across six phases (Phase 0 cache contract evolution; Phase
1 deterministic Stage 1 classifier; Phase 2 trigger rules schema;
Phase 3 real Anthropic SDK Stage 2; Phase 4 pipeline integration;
Phase 5 corpus learner option-c).

META_LEDGER entries #34-#36 capture: round-1 PASS audit, IMPLEMENT,
and SUBSTANTIATION. Three audit advisories (extract() boundary,
TeamServerRules typo, corpus learner table-source) all addressed
inline during implementation.

A proactive QorLogic Fixer code-quality sweep before commit produced
2 MED + 2 LOW findings; both MEDs landed (fail-soft on non-text
content blocks; v2->v3 backfill integration test) with one surfacing
a real defect (the migration's TYPE string was rejecting reads on
pre-v3 rows with NONE classifier_version; corrected to TYPE
option<string>).

SYSTEM_STATE.md adds the Priority C v1.1 section: schema state (v4),
architectural properties achieved (heuristic-first determinism +
LLM-only-when-needed + rule-version-driven cache invalidation + all
four "dynamic" angles wired), audit cycle outcomes.

Merkle seal: SHA256(content_hash + previous_hash) =
b37003661820e2ef80591b9d0cfdeac3df092d6d9b4b5d87e3036e7ccf37d95b
(content_hash e8b1b6b6..., previous_hash dcb61910... = Priority C
v1 SEAL at Entry #33).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
)

team_server/auth/allowlist_sync.py reconciles channel_allowlist against
the workspace table from config.slack.workspaces[]: per-team_id
additive + subtractive sync. Idempotent; picks up operator YAML edits
on next restart. Workspaces in YAML without a corresponding workspace-
table row (no OAuth completed yet) are logged and skipped — they get
picked up on the next sync after OAuth completes.

team_server/app.py lifespan: calls sync_channel_allowlist after
ensure_schema + config load, before worker registration. The Slack
runner's _channel_ids query sees populated rows on first poll cycle.
Sync failures log+continue so a partial YAML doesn't block startup.
Config load is now done once at the top of the lifespan body and
passed through to both the allowlist sync and the corpus learner
registration (deduplication of _load_config_or_default calls).

Implementation note: SurrealDB v2 strict-types `record<workspace>` on
channel_allowlist.workspace_id requires `type::thing()` coercion (the
SELECT id from workspace returns a 'workspace:<rid>' string; passing
that string back into CREATE/DELETE without coercion fails the field
type check). Pattern matches the v1.0 schema migration's existing use
of type::thing in _migrate_v1_to_v2.

Tests: 7 functionality tests across allowlist_sync (5: insert / idempotent
/ skip-not-in-yaml / skip-not-in-db / removal-on-yaml-edit) and lifespan
integration (2: lifespan invokes sync at startup; lifespan continues
when sync raises).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ge (closes #160 first half)

events/team_server_bridge.py provides two pure functions:
- is_team_server_payload(payload) — predicate distinguishing team-
  server-shaped events ({source_type, source_ref, content_hash,
  extraction}) from legacy CodeLocatorPayload-shaped events
- bridge_team_server_payload(payload) — maps to IngestPayload shape
  (source='slack'|'notion', empty repo/commit_hash, summary→description,
  context_snippet→source_excerpt). source_type='notion_database_row'
  normalizes to source='notion'. Handles both new dict-shape decisions
  and the legacy interim-claude-v1 paragraph-split string-shape.

events/team_server_consumer.py spawns a periodic asyncio task that:
1. Calls pull_team_server_events to fetch new events from the team-
   server's /events HTTP endpoint
2. Filters team-server-shaped events via is_team_server_payload
3. Bridges via bridge_team_server_payload
4. Invokes inner_adapter.ingest_payload directly (bypasses JSONL —
   team-server events have their own canonical home in the team-
   server's SurrealDB; per-author JSONL files would be redundant)

Defensive unwrap (audit-round-2 Finding A): get_ledger() returns
TeamWriteAdapter in team mode; its ingest_payload emits an
'ingest.completed' event via _writer.write BEFORE delegating. Without
the unwrap, consumer-driven ingest would echo team-server events into
per-dev JSONL files → git push → other devs replay → O(N²) cross-dev
replay amplification per team-server event. The
`getattr(adapter, "_inner", adapter)` line in
start_team_server_consumer_if_configured is the load-bearing control;
it falls through to the bare adapter in solo mode (verified:
SurrealDBLedgerAdapter has no _inner attribute).

server.py serve_stdio: spawns the consumer task in parallel with the
existing dashboard sidecar; cancels and awaits on shutdown via
try/finally. Opt-in via BICAMERAL_TEAM_SERVER_URL env; consumer task
returns None when unset.

Tests: 7 functionality tests including
test_consumer_unwraps_team_write_adapter_does_not_echo_to_jsonl which
constructs a real TeamWriteAdapter with a recording EventFileWriter
stub and asserts _writer.write was NOT called — the load-bearing test
that catches the audit-round-2 echo-amplification defect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…vents (closes #160 second half)

events/materializer.py replay loop adds a dispatch branch for
event_type in ('ingest', 'ingest.completed') with a team-server-shaped
payload: routes through is_team_server_payload + bridge_team_server_payload
(from events/team_server_bridge.py landed in Phase 1.5) and invokes
inner_adapter.ingest_payload with the bridged IngestPayload.

The new branch sits BEFORE the existing 'ingest.completed' dispatch
and is gated on the is_team_server_payload predicate. Legacy
CodeLocatorPayload-shaped events with event_type='ingest.completed'
fall through unchanged; only team-server-shaped payloads route via
the bridge.

This closes the second half of #160 — Phase 1.5 closed the load-
bearing path (per-dev consumer pulling events directly), while this
phase covers the secondary path where team-server events end up in
git-tracked JSONL files (e.g., if a future flow appends team-server
events to per-author JSONL for offline replay). Defensive
infrastructure for v1.next; not load-bearing for v0 functionality.

Tests: 6 net-new functionality tests in test_materializer_team_server_pull.py:
- dispatches team_server 'ingest' event through bridge
- bridges slack extraction to IngestPayload (full shape assertion)
- bridges notion_database_row to source='notion' (normalization)
- skips events with empty extraction.decisions
- legacy 'ingest.completed' with non-team-server payload still
  routes to original dispatch (regression coverage)
- malformed payload (missing 'extraction') is shape-checked and
  skipped without crashing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three-round audit cycle (VETO → VETO → PASS) for closing v0 release
blockers issues #160 (materializer event_type mismatch) and #161
(channel_allowlist not populated).

META_LEDGER entries #37-#41 capture: round-1 VETO (infrastructure-
mismatch — pull_team_server_events had zero production callers),
round-2 VETO (specification-drift — sketch passed wrapped adapter
without unwrap; would echo events O(N²) cross-dev), round-3 PASS,
IMPLEMENT, SUBSTANTIATION.

SHADOW_GENOME #7 heuristic catalog grew 4→6 across this branch:
- Heuristic 5 (upstream-consumer) — Entry #37
- Heuristic 6 (wrapper-side-effect) — Entry #38
The catalog is the productive deposit beyond the code; each
heuristic is a durable detection pattern reusable in future audits.

SYSTEM_STATE.md adds the v0 release-blockers section: end-to-end
ingest pipeline now functional (Slack OAuth → workspace row → YAML
allowlist sync → channel_allowlist → Slack worker polls → heuristic+
LLM extraction → team_event → /events HTTP → per-dev consumer pulls
→ bridges to IngestPayload → per-dev local ledger).

Merkle seal: SHA256(content_hash + previous_hash) =
7cc405fc8d39f468d502da669982c88321ce3a84bb571d28e0b14be86ab56bdd
(content_hash 14e387b1..., previous_hash b3700366... = Priority C
v1.1 SEAL at Entry #36).

Closes #160, closes #161.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rift cut

Brings commits 1-22 of #153's branch into a fresh dev-based branch:
team-server scaffold, OAuth, workers, cache evolution, classifier,
LLM extractor, channel_allowlist sync (#161), team-server event
consumer + bridge (#160). Skips the post-238c0ce commits that drift
architecturally from dev's #175 preflight Step 5.6 rework and the
events/session_end_bridge.py vs bicameral-capture-corrections split.
Type fixes (3 mypy errors that blocked PR #153):
- llm_extractor.py: _one_attempt return type now tuple[str, list[Any] | str | None];
  isinstance assert added on the ok branch before _success(decisions=...)
- app.py: _interim_extractor as adapter function (not bare alias) so the
  worker Extractor protocol Callable[[str], Awaitable[dict]] is honored;
  the underlying llm_extractor.extract takes 2 args (text + triggers); the
  adapter passes triggers=[] for the legacy fallback path

Plus ruff auto-fix + ruff format pass to bring 44 files inline with current
ruff config on dev (the cherry-picked commits pre-dated some style updates).

Locally: mypy . (131 files), ruff check ., ruff format --check . all pass;
28/28 team-server tests pass.
@Knapp-Kevin Knapp-Kevin added flow:feature Standard feature/fix PR targeting BicameralAI/dev (the default flow) P1 High: ship this milestone; user-impacting bug or committed feature infra Infrastructure / build / CI / repo-admin work feat Feature work or user-visible capability labels May 4, 2026
@Knapp-Kevin Knapp-Kevin had a problem deploying to recording-approval May 4, 2026 21:49 — with GitHub Actions Failure
@coderabbitai

coderabbitai Bot commented May 4, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bed6181b-f2a2-419c-ae9a-2afa7b339a3d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/priority-c-team-server-fresh

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Closes the e2e flake observed on PR #181 (run 25345393569): Flow 5 (PM
history+ratify, MCP-layer) and Flow 3 (commit→sync, agentic) failed
because a stale ~/.claude/projects/-tmp-desktop-clone/memory/MEMORY.md
from a prior run let the agent answer Flow 5's prompt directly from
disk instead of invoking bicameral.history. Flow 3's failure cascaded
because its ledger snapshot relies on Flow 5's bicameral call to drain
the post-commit JSONL queue via EventMaterializer.replay_new_events
(documented at run_e2e_flows.py:342-349).

Diagnosis evidence (from /qor-debug Phase 1 four-layer analysis):
- PASS run #178 Flow 5.ndjson L5: 'File does not exist' on MEMORY.md →
  agent runs ToolSearch + bicameral_history. Flow 5 PASS.
- FAIL run #181 Flow 5.ndjson L5: full MEMORY.md content shown → agent
  reads memory + git-log + answers from disk. Zero bicameral calls.
- The team-server materializer hunk at events/materializer.py:88-106
  is correctly gated (is_team_server_payload requires both source_type
  AND extraction keys; standard MCP payloads have neither). Dormant in
  the e2e flow. NOT the regressor.

Fix:
- New helper clean_claude_memory_for_repo(repo_path) in
  tests/e2e/_harness_setup.py that purges ~/.claude/projects/<key>/memory/
  where <key> is the absolute repo path with / and \ replaced by -.
- Wired into tests/e2e/run_e2e_flows.py::main as _clean_claude_memory(),
  called alongside the existing _clean_ledger / _reset_desktop_repo /
  _bootstrap_bicameral_dir cleanup chain.
- Regression-locking unit tests in tests/test_e2e_harness_memory_purge.py
  cover: purges-when-present, no-op-when-absent, scoped-to-target-project
  (does NOT touch other repos' memory on the same runner).

3/3 tests pass; ruff + ruff format + mypy all clean.

Refs PR #181, root-cause via /qor-debug session
PR #181 run 25377562963 hit a 600s timeout on Flow 2 with a sequence
length that exceeded the post-#175 baseline. Same age-out pattern as
the prior 300s → 600s bump after #154's PostToolUse hook landed: every
time a new turn lands on Flow 2's hot path (e.g. #175's AskUserQuestion
disambiguation gate at preflight Step 5.6.1), the cap eats into its
margin until a CI flake materializes.

900s = 50% headroom over Flow 2's observed runtime today. Comment at
lines 46-58 documents the full bump history so the next age-out has
context.

No product code change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat Feature work or user-visible capability flow:feature Standard feature/fix PR targeting BicameralAI/dev (the default flow) infra Infrastructure / build / CI / repo-admin work P1 High: ship this milestone; user-impacting bug or committed feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant