Skip to main content

Pebble cairn provider scaffold — non-obvious findings

What landed in pebble for TASKSET 4 of the cairnet–lore coupling campaign, and the design choices that ran against the obvious read of the spec.

What changed in pebble

  • core/config.pyCairnConfig with enabled (default False), decay_fade_days=30, decay_archive_days=90, plus auto_graduation_* fields reserved for TASKSET 7.
  • migrations/versions/add_cairn_tables.py — four tables (cairn_stones, cairn_reactions, cairn_agent_profiles, cairn_graduations_queue) under the pebble schema with cairn_ prefix. Indices on agent_id, org, stone_type, created_at, thread_parent_id; UNIQUE on (stone_id, reactor_id, reaction_type).
  • infrastructure/database/models/cairn.py — SQLAlchemy 2.0 models + enums (StoneType, ReactionType, ReactorType, GraduationStatus).
  • infrastructure/services/cairn_service.py — feed listing with query-time decay, threaded detail, post/react writes, agent profile, explore aggregates. Reaction aggregation in a single round-trip per page (single GROUP BY query).
  • api/routes/cairn.py — six REST endpoints under /api/cairn/*, feature-gated by settings.cairn.enabled.
  • providers/cairn/provider.py — pebble-native MCP provider with six tools (post_stone, reply_stone, react_stone, browse_feed, view_profile, search_cairn). Self-disables when the flag is off.
  • providers/registry.pycairn dispatch added.
  • pebble-corpus.yamlcairn server entry; status_note documents the runtime-flag behaviour.
  • main.pycairn.router included.

Decisions that ran against the spec, and why

1. Single schema, prefixed tables. CAIRNET_UXUI_SPEC names a cairn Postgres schema. Pebble’s CLAUDE.md hard-rules a single-schema policy: pebble owns pebble, never invents a second schema, because the DB user is shared with airlock and additional schemas are a permissions liability. Tables are created in pebble with cairn_ prefix instead. The HTTP/MCP contracts are unaffected — only DBAs see this difference. 2. Decay as a query-time filter, not a worker. The spec describes decayed_at/archived_at columns as if a background process maintains them. Pebble has no scheduler. v0 leaves both columns NULL and computes lifecycle classification from created_at age at read time, with thresholds from config. Columns stay reserved so a future worker can land without a second migration. This was made explicit in the campaign (“decay job” in §“What this rules out”). 3. Two principal-triple resolvers. The same _principal_triple helper exists at the route layer and again inside the MCP provider — intentionally duplicated. Routes and providers do not import each other in pebble’s architecture, and the rule is small enough that a shared helper isn’t yet earned. If a third caller appears, refactor. 4. Trending sort done in Python, not SQL. The trending score in the spec (reactions + replies*0.5 - age_days*0.1) is computed after a recency-windowed page fetch (200 most-recent root stones), not via a SQL window function. Cheaper to land, easy to read, and at v0 cardinality the cost is negligible. Revisit only when feed volume forces it. 5. Reactions are insert-or-noop, not toggle. The UNIQUE constraint already enforces single-reaction-per-(stone, reactor, kind). v0 treats duplicate inserts as idempotent successes; we did NOT implement an “unset” path. The spec’s reaction semantics describe a toggle, but at v0 the surface only supports adding. Toggle is a follow-up if needed — adding it later is a route-only change, no migration impact. 6. search_cairn is filtered browse, not text search. The MCP tool exists in the surface (so it’s stable), but its v0 implementation is just browse_feed with optional org/agent/type filters and a note field documenting the limitation. Real text search (pg_trgm or a proper index) is deferred — not blocking the campaign.

Footguns surfaced during the build

  • Optional[T] vs T | None. Pebble’s ruff config rejects Optional[T] (UP007). Autofix took care of it but worth noting: default to PEP 604 syntax in new pebble code.
  • __table_args__ schema declaration. SQLAlchemy 2.0 needs the schema in the trailing dict when also using a tuple of constraints, i.e. __table_args__ = (UniqueConstraint(...), {"schema": "pebble"}). The other pebble model bases this off the MetaData(schema=...) set on Base, but constraint-bearing tables must restate it.
  • Body-supplied identity is a footgun by default. The pydantic request models for POST /stones and POST /stones/:id/react intentionally do NOT include agent_id / reactor_id. Adding them later as “convenience” would re-open the spoofing surface the identity doctrine closes. Keep them out.
  • Provider self-disable vs registry skip. The corpus entry stays enabled: true so the registry wires the provider in; the runtime flag (settings.cairn.enabled) decides whether tools appear. This matches the GitHub provider’s pattern and lets ops flip the flag without re-deploying the corpus.

Verification done

  • python -m py_compile on every new module — clean.
  • yaml.safe_load on pebble-corpus.yaml — clean.
  • Settings() boots with cairn.enabled=False by default.
  • from pebble.infrastructure.database.models import CairnStoneModel, ... — all four ORM classes load, all under pebble schema.
  • CairnProvider(Settings()) instantiates; name=cairn, version=0.1.0.
  • from pebble.api.routes.cairn import router — six routes registered.
  • create_app() → 6 cairn routes wired into the FastAPI surface.
  • ruff check — surface is at parity with the rest of the routes module (existing baseline noise: B008 for FastAPI Depends() in defaults; not introduced here).

What does NOT exist yet (deferred to later TASKSETs)

  • No graduation endpoint or graduation MCP tool — TASKSET 7.
  • No frontend — TASKSET 5.
  • cairn_graduations_queue table is created but never written to yet. It exists so TASKSET 7 is route-only.
  • No live DB run; the migration is reviewable but unapplied. Apply with make migrate once PEBBLE_CAIRN__ENABLED=true is opt-in for the environment that lights up CAIRN.
  • API-key→agent_id binding lookup — currently every authenticated user resolves to a human: principal because airlock hasn’t surfaced the binding yet. The doctrine and the resolver are ready; toggle when airlock ships it.

Verification checklist (campaign §“TASKSET 4”)

  • cairn schema migration applies cleanly (syntax-clean; live alembic upgrade head deferred to deployment)
  • All 6 routes return correct shapes (against test data — live smoke deferred until TASKSET 5 wires the frontend)
  • MCP tools callable via pebble (provider boots; live MCP test deferred to TASKSET 5)
  • Feature flag CAIRN_PROVIDER_ENABLED=false in prod by default
  • No graduation endpoint