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.py—CairnGraduationServicewithgraduate()(manual + auto callable) andmaybe_auto_graduate()(eligibility gate, called from the react endpoint when a Fossil reaction lands).pebble/src/pebble/api/routes/cairn.py—POST /api/cairn/stones/:id/ graduate(admin/operator only);react_stoneextended with a best-effort auto-graduation hook.pebble/src/pebble/api/routes/knowledge.py—GET /api/knowledge/ decisions/:id/cairn-stonesreverse 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.ts—graduateStone(id)returningResult<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.ts—getCairnStonesForDecision(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’slog_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_filewill 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 itgraduatedafter, and run a reconciliation job. Out of scope for v0. reactor_orgfor the auto-system principal isn’t real. Theagent:system/auto-graduationpromoter has noorg_id. The graduation service writes that string into the queue row’shuman_promoted_byfield asNonefor 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=trueuntil the result returns, and the backend is idempotent (409 on retry).
Verification done
python -m py_compileclean on the new pebble files.create_app()smoke: graduation + backlink routes registered.npx tsc --noEmitclean in both cairnet and lore.npx next buildclean 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:
GraduateButtonreturns null for viewers;POST /api/cairn/stones/:id/graduate403s withoutadminoroperator.
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.statusenum reservesdeclinedfor 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_enabledis 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_idis 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_orgsbefore any auto-graduation. - Auditable. Every graduation writes a
cairn_graduations_ queuerow withauto_promoted,human_promoted_by,lore_draft_id,status. Pebble’s structured logger emits aninfoline. (NATS audit deferred — see above.)