Skip to content

fix(ledger): derive transition status from destination, not target - #28

Merged
lancekrogers merged 1 commit into
mainfrom
fix/ledger-transition-status
Jul 16, 2026
Merged

fix(ledger): derive transition status from destination, not target#28
lancekrogers merged 1 commit into
mainfrom
fix/ledger-transition-status

Conversation

@lancekrogers

Copy link
Copy Markdown
Member

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 —

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.

…estination, not target

annotateArtifact read payload["target"] as the new status on transitioned events, but fest emits the artifact kind or action there ("festival", "reset", "blocked") and the real destination status in payload["to"]. After ingest, every transitioned festival rendered status "festival" and every reset task rendered "reset".

Derive the status from "to" (path.Base normalizes dungeon aliases) and stop reading "target" as a status. The existing normalization test was false-green because its fixtures used status-shaped "target" values the producer never emits; those are corrected to real payloads, and a table-driven regression covers festival/dungeon-alias/reset/blocked using the exact fest producer shapes. Follow-up to #27 (CA0002 / D008).
Comment thread internal/ledger/kinds.go
// Prefer the trailing path segment as a coarse status when no
// explicit target/status is present.
n.Status = path.Base(to)
if subtype == "transitioned" {

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.

Correct contract: fest transitions put destination status in to and artifact kind/action in target (festival, reset, blocked). Reading target as status made every festival show as status "festival". Preferring path.Base(to) for transitioned matches producer shapes and dungeon aliases like dungeon/completedcompleted.

@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

Small, precise ledger ingest fix: transition status is now taken from payload to (with path.Base for dungeon aliases), not from target, which names the artifact kind/action. Tests cover festival status change, dungeon alias, task reset, and blocked using producer-shaped payloads.

Key Findings

  • No blocking issues. Prior behavior was a real graph corruption bug for fest transitions.
  • Workitem normalization fixtures were corrected to realistic from/to/target values as part of the same contract.

What's Done Well

  • Clear comment documenting the fest producer contract
  • Table-driven regression that would have caught the original mistake
  • Minimal blast radius (kinds.go + tests)

Staff Standard

Yes. Ship it.

@lancekrogers
lancekrogers merged commit 68cedb3 into main Jul 16, 2026
1 check passed
@lancekrogers
lancekrogers deleted the fix/ledger-transition-status branch July 16, 2026 00:17
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