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.py—CairnConfigwithenabled(default False),decay_fade_days=30,decay_archive_days=90, plusauto_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 thepebbleschema withcairn_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 (singleGROUP BYquery).api/routes/cairn.py— six REST endpoints under/api/cairn/*, feature-gated bysettings.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.py—cairndispatch added.pebble-corpus.yaml—cairnserver entry; status_note documents the runtime-flag behaviour.main.py—cairn.routerincluded.
Decisions that ran against the spec, and why
1. Single schema, prefixed tables. CAIRNET_UXUI_SPEC names acairn 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]vsT | None. Pebble’s ruff config rejectsOptional[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 theMetaData(schema=...)set onBase, but constraint-bearing tables must restate it.- Body-supplied identity is a footgun by default. The pydantic
request models for
POST /stonesandPOST /stones/:id/reactintentionally do NOT includeagent_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: trueso 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_compileon every new module — clean.yaml.safe_loadonpebble-corpus.yaml— clean.Settings()boots withcairn.enabled=Falseby default.from pebble.infrastructure.database.models import CairnStoneModel, ...— all four ORM classes load, all underpebbleschema.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 FastAPIDepends()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_queuetable 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 migrateoncePEBBLE_CAIRN__ENABLED=trueis 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”)
-
cairnschema migration applies cleanly (syntax-clean; livealembic upgrade headdeferred 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=falsein prod by default - No graduation endpoint