feat(ledger): ingest campaign event ledger into graph (D008, CA0002) - #27
Merged
Conversation
…t ledger into graph (D008) Wire camp-graph build/refresh to re-read the full campaign ledger on every rebuild and emit causal nodes/edges the filesystem scan cannot infer. - Add action/decision node types and promoted_to/produced/because_of edges - Bump graph schema to graphdb/v3alpha1 - Depend on camp pkg/ledgerkit v0.3.0-rc.2 - Unit tests cover determinism, unknown-kind skip, workitem normalization
… review iterate findings Split ingest into focused files under the 500-line guideline, guard reconciled self-kind re-dispatch, and bound workitem marker walk depth.
obey-agent
approved these changes
Jul 15, 2026
obey-agent
left a comment
Member
There was a problem hiding this comment.
Verdict: Approve
Overview
D008 lands cleanly: full-ledger re-read on every rebuild/refresh emits causal action/decision nodes and produced/promoted_to/because_of edges that filesystem scan cannot infer. Schema bump to graphdb/v3alpha1 with rebuild-on-mismatch keeps compatibility honest. Dependency on camp/pkg/ledgerkit @ v0.3.0-rc.2 matches fest emission.
Key Findings
No blocking issues.
Residual notes (non-blocking)
- RC dependency — pinning
camp v0.3.0-rc.2is fine for the festival, but promote to a stable camp tag before treating graph+ledger as production-locked. - Full re-read cost — correct for determinism; on huge multi-year ledgers, watch cold rebuild time and consider incremental ingest only if profiling demands it (not now).
- Workitem walk depth bound (8) — good; document that deeply nested design packs beyond the bound simply will not normalize scope keys until the walk limit is revisited.
What's Done Well
- Deterministic ids/timestamps (event-derived, not wall clock)
- Unknown kinds skipped, not fatal
- Reconciled self-kind re-dispatch guarded + depth-limited
- File split under ~500-line guideline after review iterate
- Real-campaign build evidence in the PR body
- Operator-visible ledger lines on
build/refresh
Staff Standard
Yes — merge after fest#272 (or ensure ledger events exist) so graphs are not empty of causal edges in practice. Stack order: emit (fest) → ingest (camp-graph) is the natural product order.
This was referenced Jul 15, 2026
lancekrogers
added a commit
that referenced
this pull request
Jul 16, 2026
## Problem Follow-up to #27 (CA0002 / D008), which merged with a ledger-ingestion contract bug. The ledger's typed `ledgerkit.Event` envelope is shared by both producer (`fest`) and consumer (`camp-graph`) at a pinned version, so it can't drift. The break is in the **untyped `payload` map**: `annotateArtifact` read `payload["target"]` as the artifact's new status on `transitioned` events — ```go if target := payloadString(ev.Payload, "target"); target != "" && subtype == "transitioned" { n.Status = target // wrong field } ``` But `fest` puts the artifact **kind or action** in `target`, and the real destination **status** in `to`: | producer | payload | correct status | |---|---|---| | `fest/internal/commands/status/atomic.go` (festival status change) | `from`, `to: <status>`, `target: "festival"` | `to` | | `fest/internal/progress/manager.go` (task reset) | `to: "pending"`, `target: "reset"` | `pending` | | `fest/internal/progress/manager.go` (task blocked) | `from`, `to: "blocked"`, `target: "blocked"` | `blocked` | After ingest, every transitioned festival rendered `Status="festival"` and every reset task rendered `Status="reset"`. Task-blocked was correct only by coincidence (`to == target == "blocked"`). Reproduced with the exact `atomic.go` payload: an `active → completed` festival transition produced `Status="festival"` instead of `"completed"`. The package's own tests didn't catch it: `TestIngest_WorkitemNormalization` used status-shaped `target` values (`"active"` / `"completed"`) the real producer never emits, so it passed for the wrong reason (false green). ## Change `annotateArtifact` now derives the status of a `transitioned` event from `to` (`path.Base` normalizes dungeon aliases like `dungeon/completed`), and never reads `target` as a status. This is faithful to the original author's own intent — they already used `path.Base(to)` as the status fallback; the fix removes the wrong `target` override and makes `to` authoritative so the latest transition wins. The `status`-payload handling (used by `created` / `completed` events) is unchanged. ## Tests - Corrected `TestIngest_WorkitemNormalization` fixtures to real producer payloads (status in `to`, kind in `target`); the assertion is unchanged and now passes for the right reason. - Added `TestIngest_TransitionStatusFromDestinationNotTarget`, a table-driven regression using the exact `fest` producer shapes: festival status change, festival→dungeon alias, task reset, task blocked. Each asserts the status comes from `to`, not `target`. ## Verification - `go build ./...` clean - `go test ./...` — all packages pass - `go vet ./...` / `just lint` clean - `gofmt` clean ## Note / possible follow-up Festival `created` events emit `status="created"` (via the `status` field, not `target`), so a just-created, never-transitioned festival records `Status="created"` rather than its initial directory status. That is a separate, milder question about the `status`-field convention and is intentionally out of scope here; flagging it for a decision.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements D008 for campaign-audit-trail-CA0002: camp-graph re-reads the full campaign ledger on every rebuild and emits causal nodes/edges the filesystem scan cannot infer.
action,decisionpromoted_to,produced,because_ofgraphdb/v3alpha1github.com/Obedience-Corp/camp/pkg/ledgerkit@v0.3.0-rc.2buildandrefreshheavy pathsReal-campaign run (obey-campaign)
producededges carrysubtype=commit,note=repo@shaDeterminism for same ledger content is covered by unit tests (stable action/decision ids + event timestamps). Live campaign totals can move between builds as the ledger grows and inference edges update.
Test plan
just lint/just buildjust test unit(187 tests)go test ./internal/ledger/ ./internal/runtime/ ./internal/graph/camp-graph buildtwice with action/produced sample renderFestival: CA0002 task
003_IMPLEMENT/05_views/02_graph_ingestion