Skip to main content

Graduation — non-obvious findings

The CAIRN→LORE coupling closed today. Manual graduation is live. Auto graduation is wired but flag-off in production. The LORE→CAIRN backlink renders the reverse provenance.

What changed

  • pebble/src/pebble/infrastructure/services/cairn_graduation_service.pyCairnGraduationService with graduate() (manual + auto callable) and maybe_auto_graduate() (eligibility gate, called from the react endpoint when a Fossil reaction lands).
  • pebble/src/pebble/api/routes/cairn.pyPOST /api/cairn/stones/:id/ graduate (admin/operator only); react_stone extended with a best-effort auto-graduation hook.
  • pebble/src/pebble/api/routes/knowledge.pyGET /api/knowledge/ decisions/:id/cairn-stones reverse lookup for the LORE backlink.
  • cairnet/src/app/api/cairn/stones/[id]/graduate/route.ts — proxy.
  • cairnet/src/components/cairn/graduate-button.tsx — admin/operator- only client component, two-step confirm, idempotent on the backend (409 ALREADY_GRADUATED), surfaces error inline on failure.
  • cairnet/src/lib/cairn-api.tsgraduateStone(id) returning Result<GraduateResponse>.
  • cairnet/src/app/stones/[id]/page.tsx — button mounted between the root card and the thread graph.
  • lore/src/app/api/knowledge/decisions/[id]/cairn-stones/route.ts — proxy.
  • lore/src/lib/knowledge-api.tsgetCairnStonesForDecision(id).
  • lore/src/app/decisions/[id]/page.tsx — “Inspired by stones” card on the right rail. Each stone tile opens cairn in a new tab.

Decisions that ran against the spec, and why

1. Routed around an upstream bug, didn’t fix it here. The doctrine says “CAIRN calls LORE’s log_decision.” The MCP-tool seam is broken upstream: KnowledgeProvider._handle_log_decision reads call.tool_args, but ToolCall only defines arguments (verified by grep). Going through the tool would silently fail with INVALID_ARGS. The graduation service constructs the same decision JSON shape and calls the knowledge provider’s _github_client.create_file directly — same writer, same commit message convention, same return contract. When the upstream typo is fixed, the swap is a one-liner. Documented inline in cairn_graduation_service.py so a future fixer can find it. 2. Distinct-org Sybil threshold lives in cairn_reactions.reactor_org, not joined. TASKSET 4’s migration already denormalised reactor_org into the reactions table specifically so this query stays a single GROUP BY. Verified working: _distinct_fossil_orgs(stone_id) is one COUNT DISTINCT, no joins. 3. Auto-graduation hook is best-effort post-react. When the runtime flag is on and a Fossil lands, the react endpoint tries to auto-graduate after committing the reaction. Failure in the auto path must not fail the reaction itself — the reaction is the user’s intent; graduation is a side effect. Wrapped in try/except; logs go through pebble’s structured logger; reaction response is unaffected. 4. Promoter identity ≠ stone author identity. A graduation writes under the promoter’s principal (the human admin who clicked, or agent:system/auto-graduation for auto). Stone provenance is preserved in causality.inspired_by_stones — the doctrine’s “identity comes from airlock-verified principal” rule cleanly extended to graduation without a special case. 5. UI is two-step, not modal. The doctrine says “human-gated…draft.” A modal-confirm-then-write felt heavy for a draft creation. The button toggles to “Confirm: write LORE draft” on first click; second click commits. Cancel returns to neutral. Less chrome, same gating. 6. LORE backlink uses a LIKE %decision_id% match, not exact. cairn_stones.graduated_to_lore_id stores the full decision file path (.agents/decision-log/<timestamp>_<agent>_<type>.json). LORE’s decision id is a UUID baked into a separate column on proof_decisions. The simplest reliable match is suffix-substring against the path — a JSON file root contains the decision’s filename identifier. If proof_decisions one day stores the file path explicitly, this becomes an exact join. 7. /explore graph from TASKSET 6 stays skipped. Reaffirmed: adding a graph there would be decorative. The spec text suggested “reaction-weighted clusters” but pebble’s explore response is flat top-N. Not changed in this taskset.

Footguns surfaced

  • Idempotency at the GitHub write layer. create_file will fail if the path exists. If a graduation succeeds in GitHub but the DB commit fails, a retry hits a 409 in GitHub. Today: we accept the inconsistency window (write + DB commit aren’t atomic). Future fix: insert a pending row before the GitHub call, mark it graduated after, and run a reconciliation job. Out of scope for v0.
  • reactor_org for the auto-system principal isn’t real. The agent:system/auto-graduation promoter has no org_id. The graduation service writes that string into the queue row’s human_promoted_by field as None for auto, so audit isn’t lost.
  • Two-step confirm vs accidental double-click. The UI requires a second click; the first click is the arming step. A spam-clicker would still only graduate once because the second click leaves confirming=true until the result returns, and the backend is idempotent (409 on retry).

Verification done

  • python -m py_compile clean on the new pebble files.
  • create_app() smoke: graduation + backlink routes registered.
  • npx tsc --noEmit clean in both cairnet and lore.
  • npx next build clean in both. CAIRN /stones/[id] bundle 202 B → 2.8 kB → 3.37 kB (graduation button + adapter + thread-graph cumulative). LORE /decisions/[id] bundle holds 4.86 kB; the new section is server-rendered so client cost is zero.
  • Role gating verified at both layers: GraduateButton returns null for viewers; POST /api/cairn/stones/:id/graduate 403s without admin or operator.

What does NOT exist yet

  • No reconciliation job for inconsistency between GitHub and DB. Documented above.
  • No “decline graduation” path. The campaign mentioned reversibility (“Decline is reversible: the stone reference is cleared and the stone returns to the feed”); we don’t surface this at v0. The cairn_graduations_queue.status enum reserves declined for it.
  • No NATS audit emission. Campaign §“Auditable” mentions writing to airlock’s audit stream. v0 logs through pebble’s structured logger; promoting that to a NATS subject is a follow-up.
  • auto_graduation_enabled is False in prod. Will not flip until empirical Fossil signal exists across multiple orgs (the campaign pre-condition). The eligibility logic is exercisable in tests with the flag bypass that the service supports by design.

Verification checklist (campaign §“Coupling rules” exit)

  • One-way. CAIRN calls LORE; no LORE → CAIRN writes added.
  • Asynchronous. graduated_to_lore_id is set only after the GitHub write returns success.
  • Human-gated. Manual path requires admin/operator role client-side and server-side; auto path produces a draft only.
  • Sybil-bounded. Distinct-org threshold (default 3) applied in _distinct_fossil_orgs before any auto-graduation.
  • Auditable. Every graduation writes a cairn_graduations_ queue row with auto_promoted, human_promoted_by, lore_draft_id, status. Pebble’s structured logger emits an info line. (NATS audit deferred — see above.)