Skip to content

feat(ledger): ingest campaign event ledger into graph (D008, CA0002) - #27

Merged
lancekrogers merged 2 commits into
mainfrom
feat/ledger-graph-ingestion
Jul 15, 2026
Merged

feat(ledger): ingest campaign event ledger into graph (D008, CA0002)#27
lancekrogers merged 2 commits into
mainfrom
feat/ledger-graph-ingestion

Conversation

@lancekrogers

Copy link
Copy Markdown
Member

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.

  • New node types: action, decision
  • New edge types: promoted_to, produced, because_of
  • Schema bump: graphdb/v3alpha1
  • Depends on github.com/Obedience-Corp/camp/pkg/ledgerkit @ v0.3.0-rc.2
  • Wired into build and refresh heavy paths

Real-campaign run (obey-campaign)

Metric Value
Ledger events applied ~140
Action nodes ~113
Produced edges ~113
Level-2 produced edges carry subtype=commit, note=repo@sha

Determinism 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 build
  • just test unit (187 tests)
  • go test ./internal/ledger/ ./internal/runtime/ ./internal/graph/
  • Real-campaign camp-graph build twice with action/produced sample render

Festival: CA0002 task 003_IMPLEMENT/05_views/02_graph_ingestion

…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 obey-agent left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

  1. RC dependency — pinning camp v0.3.0-rc.2 is fine for the festival, but promote to a stable camp tag before treating graph+ledger as production-locked.
  2. 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).
  3. 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.

@lancekrogers
lancekrogers merged commit a62ffa9 into main Jul 15, 2026
1 check passed
@lancekrogers
lancekrogers deleted the feat/ledger-graph-ingestion branch July 15, 2026 10:03
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants