Skip to content

fix(render): self-relationships in-box, ownership as line style - #28

Merged
jbeda merged 4 commits into
mainfrom
fix/mermaid-self-relationships
Jul 26, 2026
Merged

fix(render): self-relationships in-box, ownership as line style#28
jbeda merged 4 commits into
mainfrom
fix/mermaid-self-relationships

Conversation

@jbeda

@jbeda jbeda commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Fixes #26. Fixes #27.

Rendering a real 13-entity model surfaced two ER defects; this fixes both and
records the resulting diagram conventions as ADR-0008.

What changed

  • Self-referential relationships no longer emit an edge (Render: self-referential relationships draw a runaway loop in the Mermaid ER diagram #26). Mermaid's
    dagre ER layout has no self-loop handling and draws a runaway arc that swamps
    the canvas, independent of label text — so no label-side workaround exists.
    The relationship becomes a row inside the entity's own block
    (Project self "0..1 — Predecessor"), carrying the target-side cardinality,
    the word owned when owned (no line to carry it), and the role. Rows are
    named self, self2, … — verified that Mermaid renders a PascalCase type in
    that position and does not disambiguate two attributes sharing a name.
  • Ownership is now the line style (Render: long roles wreck Mermaid edge labels, and ownership is dropped when a role is set #27): solid -- (identifying) for
    owned, dashed .. (non-identifying) for referenced and for an omitted
    ownership. relationshipLabel is now the role or the empty string; the
    ownership and cardinality fallbacks are gone (ADR-0002: precise counts
    live in the Markdown table).
  • Ownership folds across a deduped pair. A parent declaring owned and the
    child declaring referenced is one identifying relationship seen from two
    ends, so the folded edge stays solid. Without this, first-declaration-wins
    would have drawn the example's Project/Policy composition as dashed. The
    dedupe key is otherwise unchanged, so genuinely distinct edges and
    contradictory reciprocal declarations still both render.
  • New semantic lint warning (never an error) when a role reads as prose —
    more than four words, or , . ; — naming note as the right field.
  • The golden example gains a self-referential relationship
    (ProjectPredecessor), which is why neither defect was caught before.
  • Docs: 04-reading-the-diagrams.md rewritten for the new line style, label
    rule, and in-box self-relationships; 06-schema-reference.md gains the
    conventions and the role vs note guidance; the parking-garage narrative's
    hand-written diagram updated; both plugin skills updated. ADR-0008 added.

Verification

  • task check passes (both goldens regenerated with task render).
  • Every Mermaid block in the changed docs was rendered through
    @mermaid-js/mermaid-cli@11 — all valid, no runaway arcs.
  • The minimal public repro from the reporting model renders as a clean in-box
    row plus one edge.

Open questions

  • Self-row naming. Rows are self, self2, self3 rather than something
    derived from the role. Deriving an identifier from arbitrary role text risks
    collisions and invalid Mermaid identifiers, so the conservative numbering was
    chosen; the role is still in the row's comment.
  • Prose-role threshold. More than four words, or sentence punctuation. It
    keeps the golden example's `Owner` or `Member` clean and catches the
    roles that wrecked the reporting model, but it is a heuristic — a role
    containing an entity accessor (`Project`.owner) would trip the
    punctuation rule.
  • --/.. on GitHub. Verified with mermaid-cli 11.16.0 and previously
    against GitHub's renderer per the brief; not re-verified on GitHub in this PR.

Draft: the review loop has not run yet.

@jbeda

jbeda commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

PR #28 — review round 1

Full round per .claude/rules/agent-workflow.md (correctness-critical surfaces:
renderer determinism, lint rules, golden fixture; ~550 lines).

Angles run: adversarial runner (Opus, 21 fixtures, base vs branch),
correctness (Opus), docs + ADR lifecycle (Sonnet), tests + cleanup
(Sonnet). Evidence: pr28-adversarial.md, pr28-correctness.md,
pr28-docs.md, pr28-tests-cleanup.md; fixtures under pr28-fixtures/.

Verdict

The core fix works — self-loops are gone, zero Mermaid parse errors across 21
generated diagrams, output byte-deterministic (25- and 60-run checks), task check green. But the ownership-fold mechanism is wrong in four directions and
the shipped docs assert a guarantee the code does not deliver.
Not mergeable
as-is.

Blocking

B1 — the ownership fold is broken; redesign it.
Four symptoms of one bad predicate:

  • Fires when it shouldn't: two relationships on the same pair differing only in
    ownership collapse to one edge with no lint diagnostic. main rendered
    both. (adversarial release.yml: guard that releases only cut from main #1 f12; correctness C1 go3.modelith.yaml — C1 adds
    that the fold ORs owned without checking the second declaration came from
    the other end, so the "one relationship, two sides" rationale doesn't apply
    at all in the same-entity case)
  • Fails to fire when it should: when both ends name distinct roles — the common
    case — one relationship draws one solid and one dashed line. Both were
    solid on main. (correctness C2 own-roles.modelith.yaml)
  • Swallows mutual owned from both ends silently, with no diagnostic.
    (adversarial Migrate .goreleaser.yaml from deprecated brews to homebrew_casks #2 f17)
  • ER()'s new comment and ADR-0008 Decision 1 therefore overclaim:
    "contradictory reciprocal declarations keep distinct keys" is true for
    cardinality but false for ownership. (adversarial Migrate .goreleaser.yaml from deprecated brews to homebrew_casks #2, correctness C2)

Settled design (user decision): fold only a genuine reciprocal — same pair,
inverse cardinality, declared from opposite ends, exactly one side owned.
Everything else stays distinct edges. Add a lint error for mutual owned
and for reciprocal ownership disagreement so conflicts surface as diagnostics
rather than being swallowed. Then make ER()'s comment, ADR-0008 Decision 1,
docs/04 and docs/06 describe what the code actually does.

B2 — self rows drop the declaring-side cardinality. 1:n renders n;
0..5:1 renders 1. The old edge markers encoded both sides. Violates the
brief's explicit no-information-loss requirement. selfComment
(mermaid.go:181) shows a raw substring instead of routing through
model.ParseCardinality as the edge path does. (adversarial #3 f21; tests
angle independently flagged the bounded-cardinality case as unpinned)

B3 — duplicate identical self-relationships render twice (self, self2)
where main deduped. selfRows has no dedupe. (adversarial #7 f14)

Non-blocking

  • N1 Prose-role heuristic, both directions: false-positives on any , .
    ;`Owner`, `Member`, `Project`.owner, v1.0 owner all warn;
    the golden example passes only by accident of using "or". False-negative on a
    66-char single word, which wrecks the label exactly as prose would.
    (adversarial Design follow-ups from the pre-OSS audit (7 items, non-blocking) #4/Skill: author property tests from a model's invariants and actions #5 f15; correctness C4)
  • N2 docs/06-schema-reference.md: "the row carries the cardinality" is
    wrong — only the target side. ADR-0008 words it correctly. (correctness C3)
  • N3 TestProseRoleIsWarning needs the TestADR_0008_ prefix per
    .claude/rules/testing.md; ADR-0008 cross-references the non-conforming name.
    (docs angle; tests angle — independently found by both)
  • N4 docs/04-reading-the-diagrams.md:157 snippet orders blocks Project
    then Policy; real output sorts alphabetically. (docs angle)
  • N5 One role can produce two lint findings (prose + backtick check).
    (correctness C5)
  • N6 %q backslash double-escape. (correctness C7)
  • N7 Symmetric: true render test is cosmetic — nothing in the render path
    reads Symmetric. Either pin real behavior or drop the case. (tests angle)
  • N8 Branch is 2 commits behind main (ADR-0007). No conflicts; rebase
    before merge. (docs angle)

Out of scope — filed separately

sanitize passes <, >, %% through: <angle> silently vanishes and
%%{init:{'theme':'dark'}}%% in a role restyles the whole diagram. Identical
on main, so not this PR's regression. (adversarial #6 f16; correctness C6
adds pre-existing hand-copy drift in docs/05-parking-garage/index.md)

Verified clean

Determinism (25- and 60-run hashes); : "" accepted; omitted ownership →
dashed; an entity literally named Self doesn't collide; hostile role
characters parse; malformed cardinality blocked at render; GO-1 cardinality
conflicts still surfaced; !ok fallback intact; prose warning is
CategorySemantic so --completeness error can never promote it; every doc
snippet matches real CLI output; ADR-0008's "refines ADR-0002" framing accurate
and 0002 needs no back-pointer; plugin.json correctly not bumped (release-cut
step, not per-PR). Tests mutation-checked: reverting the production code behind
the four highest-value assertions fails all four.

@jbeda

jbeda commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

PR #28 — round 1 fixes

Every fix below was demonstrated by running the named fixture under
.scratch/reviews/pr28-fixtures/ against a binary built from main+branch
(before) and from the fixed branch (after). Diagrams re-rendered through
npx -y @mermaid-js/mermaid-cli@11; PNGs in .scratch/reviews/mmd-round1/.

B1 — the ownership fold, redesigned. FIXED

The fold predicate now lives in (*edge).folds (internal/render/mermaid/mermaid.go).
Two declarations that already agree on pair + canonical cardinality (normalized
to the sorted-pair orientation, so inverses match) + label merge in exactly two
cases:

  • same declaring end, same ownership — an exact duplicate, which would draw
    two indistinguishable lines;
  • opposite ends, at most one claiming owned — one relationship seen from
    two sides; the merged edge is solid if either end said owned.

Everything else draws both lines. byKey became map[string][]*edge so a
non-folding collision doesn't leave a later exact duplicate comparing against
the wrong edge.

Two new lint errors in runReciprocity (internal/lint/lint.go), both
guarded by the existing "exactly one declaration in each direction" gate so a
legitimate multi-edge pair (Owner and Member) is never touched:

  • mutual ownership — both ends declare ownership: owned;
  • reciprocal ownership conflict — the ends disagree on ownership, their
    cardinalities are inverses (so no cardinality conflict is reported — the
    code continues in that case rather than piling on), but their roles differ,
    so the fold cannot resolve it and the diagram would draw one solid and one
    dashed line for one relationship. Roles are compared through normalizeRole
    (backticks stripped, trimmed) so `Part` and Part still match.

Before / after

fixture before after
f12-own-only-difference (same end, ownership differs) 1 edge — one declaration silently vanished 2 edges: Alpha ||--o{ Beta, Alpha ||..o{ Beta
correctness/go3 (same end, shipping vs billing address) 1 edge 2 edges
f13-own-diff-samerole 1 edge 2 edges
f17-mutual-owned / f07-dedupe-match (both ends owned) 1 edge, no diagnostic 2 solid edges + error [semantic] .../ownership: mutual ownership: Alpha→Beta and Beta→Alpha both declare ownership "owned" …
correctness/own-roles (owned/referenced, roles differ) 2 edges, one solid one dashed, no diagnostic same 2 edges + error [semantic] …: reciprocal ownership conflict: … their roles differ ("part" vs "whole") …
correctness/own-conflict (owned/referenced, no roles) 1 solid edge unchanged — 1 solid edge (the genuine reciprocal)
correctness/go1 (cardinality conflict) 2 edges + cardinality error unchanged

Pinned by TestADR_0008_FoldsOnlyGenuineReciprocals (6-case table covering all
of the above) and TestADR_0008_ReciprocalOwnershipConflictIsError (6-case
table, asserting severity, category and path, plus the three clean cases).
TestADR_0008_OwnershipFoldsAcrossDeclarations still passes unchanged.

Docs corrected to match

  • ER()'s inline fold comment — the old "contradictory reciprocal declarations
    keep distinct keys … modelith lint reports the contradiction as an error"
    was true for cardinality only. Replaced by a doc comment on (*edge).folds
    stating the two merging cases and why everything else draws.
  • ADR-0008 restructured to four decisions. The old Decision 1's claim that
    a parent's owned and a child's referenced "fold into one edge" is now
    Decision 2, qualified with the full predicate, the "no declaration
    disappears" rule, and the two lint errors. A new Consequences paragraph
    records the trade (honest diagram over tidy one).
  • docs/04-reading-the-diagrams.md — the "Ownership belongs to the
    relationship" paragraph gained the three non-folding cases as a list, and the
    worked-example bullet now says the declarations must agree on cardinality,
    role and ownership.
  • docs/06-schema-reference.md — the fold bullet qualified; two new
    bullets in the linter's error list.
  • plugin/skills/domain-model-{author,lint}/SKILL.md — the reciprocity
    lines gained the two ownership errors.

B2 — self rows dropped the declaring-side cardinality. FIXED

selfComment emitted the substring after :. It now emits the declared
cardinality whole. That is strictly more information than the edge it replaces
carried (the edge's crow's-foot glyphs round 0..5 to "one or many"), and it
needs no strings.Cut surgery, so the "raw substring" path the review objected
to is gone rather than re-routed. An unparseable cardinality is still shown
whole, which the existing bogus case pins.

f21-self-variants, before → after:

Node self  "n"            →  "1:n"
Node self2 "2 — Peer"     →  "1:2 — Peer"
Node self3 "1 owned"      →  "0..5:1 owned"     ← the bounded declaring side, recovered
Node self4 "n — Sibling"  →  "n:n — Sibling"

Pinned by TestADR_0008_SelfRowCarriesBothCardinalitySides (7 cases, including
0..5:1). Golden examples/example.modelith.md regenerated via task render:
Project self "0..1 — Predecessor""1:0..1 — Predecessor" (one line).

B3 — duplicate identical self-relationships rendered twice. FIXED

selfRows keeps a seen set on the rendered comment and skips a repeat, so
numbering stays contiguous. Keying on the rendered text rather than a
canonical form is the conservative choice: only rows a reader could not tell
apart are dropped.

f14-self-dup before: self + self2, identical text. After: one self row.
f14-self-dup.png confirms a single-row box. Pinned by
TestERSelfRelationshipsDedupe, which also asserts a third declaration
differing only in ownership is kept as self2.

N1 — prose-role heuristic, both directions. FIXED

readsAsProse now normalizes the role the way the diagram draws it (backticks
off, trimmed) and tests three signals: more than roleLabelMax (40) runes, more
than four words, or a ; anywhere / a trailing .. The blanket
ContainsAny(",.;") is gone — a comma separates a short list of role names, and
a full stop is as often an accessor or a version as a sentence end.

f15-prose-roles before → after (warn?):

role before after
the record this one supersedes warn warn
`Owner`, `Member` warn (false positive) clean
`Project`.owner warn (false positive) clean
e.g. Owner warn (false positive) clean
66-char single word clean (false negative) warn
primary contact for billing purposes warn warn
`Owner` or `Member` clean clean

TestADR_0008_ProseRoleIsWarning gained the three false-positive cases and the
long-single-word case. Two existing cases changed expectation deliberately:
owner, or member (comma) now clean — it is the list shape N1 says must not
warn. The full stop and semicolon cases still warn.

N2 — docs/06 "the row carries the cardinality". FIXED

Now reads "the declared cardinality in full (both sides — the two ends of the
line it replaces)", which is what B2 makes true.

N3 — test name. FIXED

TestProseRoleIsWarningTestADR_0008_ProseRoleIsWarning. ADR-0008's closing
line now points at TestADR_0008_* in both packages rather than naming the
non-conforming test. go test -list shows nine TestADR_0008_* tests.

N4 — docs/04 snippet ordering. FIXED

The self-relationship snippet now orders Policy before Project. Verified by
building .scratch/reviews/docs-check/doc04-self.modelith.yaml — the model the
snippet depicts — and diffing real CLI output against the snippet: identical.
Also reordered the first "three empty boxes" snippet (Project, User, Policy
Policy, Project, User), same class of drift, not separately filed.

N5 — one role, two findings. FIXED

The undefined-term check on a role is now the else branch of the prose check:
rewriting a prose role is the fix that comes first, and the terms buried in the
sentence will likely change with it. Demonstrated with
correctness/roles.modelith.yaml role the `Widget` this one supersedes:
before, a prose warning and an undefined-term warning at the same path; after,
the prose warning only. Pinned by TestProseRoleRaisesOneFinding, which asserts
exactly one finding at that path and that it is the prose one.

N6 — %q backslash double-escape. FIXED

sanitize now drops \ (and maps \r, \t to spaces), so nothing reaches
%q that it would escape. %q is kept as defence in depth. Pinned by the
a\b case in TestADR_0008_SelfRowCarriesBothCardinalitySides.

N7 — cosmetic Symmetric: true render case. FIXED

Dropped from TestERMultipleSelfRelationships (nothing in the Mermaid path
reads it) and replaced with real coverage where the flag does surface:
TestRenderEntity_SymmetricRelationship in internal/render/markdown pins the
- \Node` — n:n — symmetric — `Peer`` line and the absence of the marker on
a non-symmetric relationship. That behavior had no test at all before.

N8 — rebase. SKIPPED (owner is handling it).

Out of scope

sanitize's <, >, %% passthrough untouched, per instruction — filed
separately.

Verification

  • task check green (vet, staticcheck, golangci-lint, -race tests,
    lint-models --completeness error, render-check, plugin validate).
  • go test -race -count=10 ./internal/render/... ./internal/lint/ green.
  • Determinism: 30 renders × 7 models, hashed as one stream, twice —
    cc62710a…bd919 both times.
  • mermaid-cli 11: example, garage, f12, f13, f14, f17, f21,
    own-roles all parse, zero errors. Visual check of each PNG: no runaway
    arcs; f21 shows all four rows with both cardinality sides; f14 a single
    row; f17 and own-roles two distinct lines.

Open questions

  1. Is "reciprocal ownership conflict" the right severity? It is an error
    as directed. But the model it fires on (own-roles: Alpha owns Beta as
    part, Beta references Alpha as whole) is arguably consistent — both
    ends agree Alpha owns Beta, they just name the two ends differently. The
    error is about the diagram being unable to draw it as one line, not about the
    model contradicting itself. A warning would be the softer read. Left as an
    error per the settled design; flagging because it can fail a CI gate on a
    model that is not actually wrong.
  2. The fold predicate keeps the label in the key, which the settled wording
    did not list. Without it, own-roles would fold and one of the two roles
    would vanish from the diagram — which "nothing may silently disappear"
    forbids. Recorded in ADR-0008 Decision 2 as part of the predicate.
  3. Two non-folding edges between the same pair overlap visually. f12.png
    draws its solid and dashed lines nearly on top of each other; the two
    markers are legible but the styles are hard to separate. This is Mermaid's
    layout, not our source, and it is the honest rendering — but it is why the
    two new lint errors matter: the diagram alone will not always make the
    duplication obvious.

jbeda added a commit that referenced this pull request Jul 26, 2026
Round-1 review fixes for #28.

The ownership fold was wrong in four directions: it fired on two
declarations from the same end that differed only in `ownership` (one
edge silently vanished), it swallowed mutual `owned` with no diagnostic,
it left a reciprocal pair with differing roles drawing one solid and one
dashed line, and `ER()`'s comment plus ADR-0008 asserted a guarantee the
code did not deliver.

Two declarations now merge in exactly two cases: an exact duplicate from
the same end, and a genuine reciprocal (opposite ends, inverse
cardinality, same label, at most one end claiming `owned`). Everything
else draws both lines, so no declaration disappears. The contradictions
the renderer refuses to swallow become lint errors: mutual `owned`, and
a reciprocal pair whose ends disagree on ownership under different roles.

Also:

- Self-relationship rows carry the declared cardinality whole, not the
  substring after the colon, so `0..5:1` no longer renders as `1`.
- Identical self-relationship declarations render one row, not two.
- The prose-role heuristic judges the role as the diagram draws it:
  length, word count, `;` or a trailing `.` — no longer any comma or
  full stop, which false-positived on role lists, accessors and version
  numbers, and no longer blind to a 66-character single word.
- A role raises at most one finding; the undefined-term check waits
  until the role is no longer prose.
- `sanitize` drops backslashes, so `%q` cannot double-escape them.
- ADR-0008, `docs/04`, `docs/06` and the plugin skills describe what the
  code actually does; the `docs/04` snippets match real CLI output.
- `TestProseRoleIsWarning` renamed to `TestADR_0008_ProseRoleIsWarning`;
  the cosmetic `Symmetric` render case is replaced by real coverage of
  the Markdown marker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Joe Beda <joe@stacklok.com>
@jbeda

jbeda commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Round 2 — blocking fix on the round-1 fix

The coordinator was right, and my open question #2 was the cause of open
question #1. Adding label equality to the fold predicate blocked the textbook
composition pattern from folding, and the "reciprocal ownership disagreement"
error I added to cover the resulting one-solid-one-dashed contradiction then
fired on correct models — advising "give both ends the same role", which is bad
modeling guidance since distinct role names per end is the whole point.

1. Label equality removed from the fold predicate

The edge key is now pair + canonical cardinality; the label is gone from it.
(*edge).folds restores the settled predicate for the reciprocal case: opposite
ends, at most one owned. Labels still matter for the same-end case, where
equality is what makes a declaration an exact duplicate — without that, a User
who is both Owner and Member of a Project (both declared from User)
would collapse to one line. TestADR_0008_FoldsOnlyGenuineReciprocals gained
that case.

One further guard: an edge folds in at most one opposite-end declaration
(edge.foldedFrom). Three declarations on a pair are not one relationship, and
runReciprocity deliberately leaves such a pair alone, so the renderer must not
merge them all into a single line. Pinned by TestERFoldsOneReciprocalPerEdge.

2. Deterministic label choice on a fold

New (*edge).merge, documented where the predicate lives: the owning end's
role
labels the line; with neither end owning, the role from the end whose
entity sorts first. The rule is order-independent, not merely
deterministic-by-iteration-order.

The Markdown keeps both roles — verified, not assumed. Rendering
own-roles.modelith.yaml:

Alpha  **Relationships**   - `Beta` — 1:n — owned — part
Beta   **Relationships**   - `Alpha` — n:1 — referenced — whole

Each entity's own relationship list is rendered independently of the fold, so
only the diagram drops the non-winning role. ADR-0002-consistent.

3. "Reciprocal ownership disagreement" error deleted

Gone from runReciprocity, along with the role field on its decl struct and
the continue that existed only to avoid piling it on a cardinality conflict.
normalizeRole stays — readsAsProse uses it. With labels out of the
predicate there is no unresolvable case left: exactly one owned folds, both
owned is the mutual-ownership error, neither owned folds to a dashed edge.

TestADR_0008_ReciprocalOwnershipConflictIsError became
TestADR_0008_MutualOwnershipIsError, keeping the mutual cases (including one
with differing roles, which must still error) and the clean cases, now asserting
HasBlocking(false) == false rather than just the absence of one message.

4. Regression guard on own-roles

Two tests, cross-referencing each other so neither can be deleted alone:

  • TestADR_0008_ReciprocalCompositionFoldsToOneEdge (mermaid) — asserts exactly
    one edge and that it is Alpha ||--o{ Beta : "part".
  • TestADR_0008_ReciprocalCompositionLintsClean (lint) — asserts zero
    findings, not merely zero errors, on a complete version of the fixture.

Before / after — own-roles

round 1 round 2
diagram Alpha ||--o{ Beta : "part" and Beta }o..|| Alpha : "whole" (solid + dashed for one relationship) Alpha ||--o{ Beta : "part" — one solid line
lint 1 error(s) — reciprocal ownership conflict 0 error(s)
exit code 1 0

mermaid-cli 11 re-render (own-roles.png): a single solid line labelled part.

The five other fixtures, re-run — all unchanged from round 1

fixture behavior verdict
f12-own-only-difference 2 edges (||--o{ and ||..o{), 0 errors still correct — nothing dropped
f13-own-diff-samerole 2 edges, same role on both still correct
f14-self-dup 1 self row still correct
f17-mutual-owned 2 solid edges + mutual-ownership error still correct
f21-self-variants 1:n, 1:2 — Peer, 0..5:1 owned, n:n — Sibling still correct
correctness/go3 2 edges (same end, ownership differs) still correct
correctness/own-conflict 1 solid edge still correct
f07-dedupe-match 2 solid edges + mutual-ownership error still correct
golden example render-check clean, no regeneration needed unchanged

All six re-rendered through mermaid-cli 11: parse clean, no runaway arcs.

Docs re-corrected

ADR-0008 Decision 2 rewritten again (roles explicitly not in the predicate,
the label-choice rule, the Markdown-keeps-both note, one lint error not two);
its Consequences paragraph rewritten. docs/04 "Ownership belongs to the
relationship" section and the worked-example bullet; docs/06 fold bullet and
error list; both plugin skills.

Verification

  • task check green; goldens unchanged (render-check clean).
  • Determinism: 30 renders × 7 models hashed as one stream, twice —
    2b3bdc09…8cda3 both times.
  • mermaid-cli 11 on own-roles, f12, f13, f14, f17, f21: all parse.

Open questions after round 2

  1. Round-1 open questions release.yml: guard that releases only cut from main #1 and Migrate .goreleaser.yaml from deprecated brews to homebrew_casks #2 are both resolved and withdrawn. No
    legitimate model reaches an ownership error now, so error is the right
    severity for mutual owned.
  2. New, minor: the foldedFrom one-reciprocal-per-edge guard is my own
    addition, not in the settled design. Without it, a pair with one declaration
    one way and two the other would merge all three into one line and silently
    drop a role. It mirrors runReciprocity's "exactly one declaration in each
    direction" gate, so I believe it is right, but it is a judgment call made
    without asking.

@jbeda

jbeda commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

PR #28 — review round 3 (final round; cap reached with an open blocker)

Delta-only round over b8c95d1..442811e (the round-2 fold correction).
Angle: delta verification (Sonnet). Detail: pr28-round3.md; fixtures under
pr28-fixtures/round3/.

Verdict: NOT CLEAN — do not merge

Round 2's correction is right as far as it goes, and I re-verified it by hand:
own-roles folds to one solid edge and lints clean (was exit 1); f12 keeps
two edges; f17 errors on mutual owned and keeps both edges; f14 dedupes to
one row; f21 preserves both cardinality sides. All confirmed.

But the foldedFrom one-reciprocal-per-edge guard — added by the fix agent on
its own initiative, not part of the settled design — is order-dependent and
silently drops a role.

B4 — CONFIRMED, blocking

Fixtures round3/order-c1.modelith.yaml and round3/order-c2.modelith.yaml are
identical models differing only in the order of two same-end declarations:

  • Alpha → Beta role P, referenced
  • Alpha → Beta role Q, referenced
  • Beta → Alpha role R, owned
c1 (P,Q,R):  Alpha ||--o{ Beta : "R"    Alpha ||..o{ Beta : "Q"    # P is gone
c2 (Q,P,R):  Alpha ||--o{ Beta : "R"    Alpha ||..o{ Beta : "P"    # Q is gone

Three declarations in, two edges out. Which role disappears depends purely on
source order, and the surviving owned edge absorbs an unrelated referenced
declaration and flips it solid. modelith lint reports 0 errors on both.

Root cause: the renderer's fold key has no "exactly one declaration per
direction" gate. runReciprocity in lint already enforces that invariant, but
these models fall outside its gate, so nothing catches it. When one side has two
or more declarations sharing a fold key, an opposite-end declaration folds into
whichever comes first in list order.

This falsifies ADR-0008's "no declaration disappears" claim — the same class of
docs-vs-code defect as round 1's B1, reintroduced by the fix for it.

Output is byte-stable for a fixed input, so the golden test cannot catch this.
TestERFoldsOneReciprocalPerEdge has exactly this shape but exercises only one
ordering.

Suggested fix (not applied — see cap below)

Gate the fold: if either side has two or more declarations sharing a fold key,
do not fold at all — emit every declaration as its own edge. Lossless and
order-independent, and it mirrors the invariant lint already enforces. Add
regression tests that render both orderings and assert identical output.

Verified clean this round

Markdown never folds, so no declaration can be lost there (checked
structurally). The label-choice rule is genuinely deterministic —
EntityNames() sorts independent of YAML and map order. All 31 pre-existing
fixtures behave as the round-1 record says they should. All 33 generated
diagrams parse under mermaid-cli 11.16.0. Byte-determinism and task check
green.

Round cap

.claude/rules/agent-workflow.md caps the loop at three rounds. That cap is
now reached with B4 open.
Per the rule this is recorded plainly rather than
the change being declared done: PR #28 stays a draft and is not mergeable in its
current state. Continuing past the cap is the user's call.

@jbeda

jbeda commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Round 4 — the ambiguity gate

Round 3 was right and my round-2 open question was well-founded. The
foldedFrom guard stopped an edge absorbing a second reciprocal but still
absorbed the first, and which declaration that was came down to list order.

The fix

1. Ambiguity gate, replacing foldedFrom. A fold is a claim that two
declarations are one relationship seen from two sides. That claim is only safe
when each end declares the line at most once. If either end declares it twice,
nothing folds for that key and every declaration draws its own line.
foldedFrom is gone — with the gate in place it can never fire, so it did not
earn its keep.

2. One definition, two consumers. The grouping now lives in
internal/model/edges.go: EdgeKey (pair + cardinality canonicalized to the
pair's orientation), DeclarationKey (that, plus the declaring end, ownership
and normalized role), EdgeGroups and EdgeGroup.AmbiguousPairing. The
renderer and the linter both read it, so the fold rule and the diagnostic
cannot drift — which is what produced rounds 2 and 3. lint.normalizeRole is
now model.NormalizeRole.

3. New lint warning (runPairing), never an error:

warning [semantic] /entities/Project/relationships/0: ambiguous reciprocal
pairing between "Policy" and "Project" at cardinality "n:1": Policy declares it
1 time(s) and Project 2 time(s), so which declaration is the reciprocal of
which cannot be determined — the diagram draws every declaration as its own
line; declare each relationship from one end only to pair them up

It points at the first declaration on the crowded end. It fires on no
existing fixture and neither shipped example
— verified by running it over all
21 f* fixtures, all 10 correctness/ fixtures, examples/example and
docs/05-parking-garage/garage: zero hits outside round3/.

4. ADR-0008 corrected. The "no declaration disappears" sentence round 3
falsified is replaced with The guarantee, stated exactly: every declaration
draws, except one indistinguishable from an earlier declaration by the same
entity, and the role of the non-labelling end of a folded reciprocal (which
the Markdown still carries). Decision 2 now states the once-per-end condition
and why it is what makes the fold safe. Mirrored in docs/04, docs/06 and
both plugin skills.

Before / after — realistic.modelith.yaml

round 2 round 4
diagram Policy }o--|| Project : "parent", Project ||..o{ Policy : "overrides"defaults gone parent solid, defaults dashed, overrides dashed — all three
swap Project's two declarations overrides gone instead same three lines, listed in the new source order
lint 0 error(s), no diagnostic 0 error(s), ambiguous-pairing warning

Order pairs

pair edge set line order
order-a / order-b identical (P solid, Q dashed, R dashed) follows source
order-c1 / order-c2 identical (P dashed, Q dashed, R solid) follows source
order-d1 / order-d2 identical (P solid, R1 dashed, R2 dashed) follows source

On "byte-identical", plainly: it is not, and I did not make it so. The
set of lines is identical for every pair — same labels, same styles, nothing
dropped, which is the property round 3 broke. What still varies is the order the
lines are listed in, because edges are emitted in declaration order
(entity name, then position). Making the bytes identical needs the emitted edge
list sorted, which would reorder the committed golden diagrams
(Account ||--o{ Keycard before Account ||--o{ Car becomes the reverse) and
would discard the author's ordering. That is a separate rendering decision, so
I stopped and am asking rather than doing it. TestERAmbiguousPairingIsOrderIndependent
asserts the sorted line sets match across all three swaps.

Fixtures re-run — no regressions

fixture round 4 expected
correctness/own-roles 1 solid edge "part", 0 errors ✅ unchanged
f12-own-only-difference 2 edges (||--o{, ||..o{) ✅ unchanged
f17-mutual-owned 2 solid edges + mutual-ownership error ✅ unchanged
f14-self-dup 1 self row ✅ unchanged
f21-self-variants 1:n, 1:2 — Peer, 0..5:1 owned, n:n — Sibling ✅ unchanged
f07, own-mutual 2 solid + error ✅ unchanged
f08, own-conflict 1 solid edge (genuine reciprocal) ✅ unchanged
go1, f09 2 edges + cardinality error ✅ unchanged
go3, f10, f11, f13 2 edges each ✅ unchanged
all f01f05, f19f21, self-* self rows unchanged ✅ unchanged
golden example, garage render-check clean, no regeneration ✅ unchanged

Every other fixture's output is byte-identical to round 2. Nothing absorbed.

New tests

  • TestADR_0008_AmbiguousPairingKeepsEveryDeclaration — the realistic model
    draws exactly the three expected lines (full equality on the sorted line set,
    not a count).
  • TestERAmbiguousPairingIsOrderIndependent — three-case table, each built
    twice with the two declarations swapped; asserts the sorted line sets match
    and that every declaration drew. This is the shape
    TestERFoldsOneReciprocalPerEdge should have had; that test is gone, since
    the guard it pinned is gone.
  • TestADR_0008_AmbiguousPairingIsWarning — six cases: fires two-forward-one-back
    and one-forward-two-back (asserting severity, category and path); silent for
    two-forward-none-back, one-each-way, two-forward-at-different-cardinalities,
    and a second forward declaration that is an exact duplicate.

Verification

  • task check green; goldens untouched (render-check clean).
  • go test -race -count=5 ./internal/... green.
  • Determinism: 30 renders × 10 models hashed as one stream, twice —
    2936068f…f1028 both times.
  • All 38 generated diagrams (every fixture plus both shipped examples)
    rendered through npx -y @mermaid-js/mermaid-cli@11: 38/38 parse, zero
    errors. PNGs in .scratch/reviews/mmd-round4/. realistic.png shows all
    three lines with all three roles.

Open questions

  1. Byte-identical output across a source reorder would need the emitted
    edge list sorted. See above — not done, needs a call.
  2. Exact duplicates are deduped before the ambiguity count, which the
    round-4 brief did not spell out. Two identical forward declarations plus one
    back therefore still folds normally, rather than tripping the gate. That is
    deliberate: an exact duplicate is indistinguishable, so no pairing question
    arises, and counting it would regress the round-1 B3 dedupe. Pinned by the
    last case of TestADR_0008_AmbiguousPairingIsWarning. Flagging it because it
    is a refinement of the stated rule, not the stated rule.
  3. The warning is per fold key, not per pair. Project declaring two 1:n
    relationships to Policy plus one n:1 back warns once; if it also had a
    1:1 pair, that would be a separate key and a separate warning. That seems
    right (they are different lines) but is a judgment call.

jbeda and others added 4 commits July 25, 2026 20:26
Rendering a real 13-entity model surfaced two ER defects.

A self-referential relationship was emitted as an ordinary edge. Mermaid's
dagre ER layout has no self-loop handling and draws a runaway arc that
swamps the canvas, independent of the label text. It now renders as a row
inside the entity's own block, carrying the target-side cardinality, the
word `owned` when owned, and the role (issue #26).

Relationship labels fell back from role to ownership to raw cardinality.
Long prose roles collided, and an owned relationship that also had a role
lost its ownership signal entirely. Ownership now rides on the connector —
solid (identifying) for owned, dashed otherwise — and the role is the only
label. Ownership folds across a pair declared from both ends, so a parent's
`owned` and a child's `referenced` draw one solid line. A new semantic lint
warning steers prose out of `role` and into `note` (issue #27).

The golden example gains a self-referential relationship, which is why
neither defect was caught before. Conventions recorded in ADR-0008.

Signed-off-by: Joe Beda <joe@stacklok.com>
Round-1 review fixes for #28.

The ownership fold was wrong in four directions: it fired on two
declarations from the same end that differed only in `ownership` (one
edge silently vanished), it swallowed mutual `owned` with no diagnostic,
it left a reciprocal pair with differing roles drawing one solid and one
dashed line, and `ER()`'s comment plus ADR-0008 asserted a guarantee the
code did not deliver.

Two declarations now merge in exactly two cases: an exact duplicate from
the same end, and a genuine reciprocal (opposite ends, inverse
cardinality, same label, at most one end claiming `owned`). Everything
else draws both lines, so no declaration disappears. The contradictions
the renderer refuses to swallow become lint errors: mutual `owned`, and
a reciprocal pair whose ends disagree on ownership under different roles.

Also:

- Self-relationship rows carry the declared cardinality whole, not the
  substring after the colon, so `0..5:1` no longer renders as `1`.
- Identical self-relationship declarations render one row, not two.
- The prose-role heuristic judges the role as the diagram draws it:
  length, word count, `;` or a trailing `.` — no longer any comma or
  full stop, which false-positived on role lists, accessors and version
  numbers, and no longer blind to a 66-character single word.
- A role raises at most one finding; the undefined-term check waits
  until the role is no longer prose.
- `sanitize` drops backslashes, so `%q` cannot double-escape them.
- ADR-0008, `docs/04`, `docs/06` and the plugin skills describe what the
  code actually does; the `docs/04` snippets match real CLI output.
- `TestProseRoleIsWarning` renamed to `TestADR_0008_ProseRoleIsWarning`;
  the cosmetic `Symmetric` render case is replaced by real coverage of
  the Markdown marker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Joe Beda <joe@stacklok.com>
Round-2 review fix. Round 1 put label equality into the fold predicate and
added a "reciprocal ownership disagreement" error to cover the contradiction
that produced. Both were wrong: a parent owning a child while the child
references the parent back, each naming its own end's role, is the textbook
composition pattern and precisely the genuine reciprocal the fold exists for.
It was drawing one solid and one dashed line and failing lint with advice
("give both ends the same role") that argues against the pattern itself.

The predicate is now what was settled: same pair, inverse cardinality,
opposite ends, at most one end claiming `owned`. Labels are out of it. A fold
picks the label deterministically — the owning end's role, or with neither end
owning, the role from the end whose entity sorts first. The other role is
dropped from the diagram only; the Markdown lists each entity's own
relationships, so both survive in the document (ADR-0002's lossy view).

The reciprocal-ownership-disagreement error is deleted. With labels out of the
predicate no unresolvable case remains: exactly one `owned` folds, both `owned`
is the mutual-ownership error, neither `owned` folds to a dashed edge.

Labels still separate two declarations from the same end, so a User who is both
`Owner` and `Member` of a Project keeps two lines. An edge folds in at most one
opposite-end declaration, mirroring the linter, which reconciles a pair only
when each direction declares it exactly once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Joe Beda <joe@stacklok.com>
…biguous

Round-4 review fix. Round 2's `foldedFrom` guard stopped an edge absorbing a
second reciprocal declaration but still absorbed the first, and which one that
was depended on list order. A Project declaring `defaults` and `overrides` to
Policy, with Policy declaring `parent` back, rendered `parent` and `overrides`
and dropped `defaults` — swap the two Project declarations and `overrides` went
instead. Both linted clean.

The single declaration is the reciprocal of one of the two, and the format
cannot say which. So the fold is now gated on that ambiguity: a fold requires
each end to declare the line at most once. Where it does not hold, nothing
folds and every declaration draws its own line — lossless and independent of
declaration order. `foldedFrom` is gone; with the gate it can never fire.

A lint warning, never an error, names such a pair: the diagram will show more
lines than the author probably means, and declaring each relationship from one
end only resolves it. It fires on no existing fixture and neither shipped
example.

The grouping both the renderer and the linter need now lives in one place,
`internal/model/edges.go`, so the fold rule and the diagnostic cannot drift
apart — which is what produced the last two rounds.

ADR-0008's "no declaration disappears" claim was falsified by this defect. It
is replaced with the guarantee stated exactly: every declaration draws, except
one indistinguishable from an earlier declaration by the same entity, and the
role of the non-labelling end of a folded reciprocal, which the Markdown still
carries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Joe Beda <joe@stacklok.com>
@jbeda
jbeda marked this pull request as ready for review July 26, 2026 03:26
@jbeda
jbeda force-pushed the fix/mermaid-self-relationships branch from f94e3d1 to 0629f96 Compare July 26, 2026 03:26
@jbeda

jbeda commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Review loop closed — ready for review

Four rounds run (three per .claude/rules/agent-workflow.md, plus one authorized past the cap for the round-3 blocker). Records: round 1 · round-1 fixes · round 2 · round 3 · round 4

Independently verified after the final fix, not taken on report:

Check Result
realistic + line-swapped copy all three declarations draw both ways; ambiguity warning fires
own-roles one solid edge, lints clean
f12 ownership-only difference two edges, nothing dropped
f17 mutual owned lint error + two solid edges
f14 duplicate self-rel one row
f21 self variants both cardinality sides preserved
determinism 10 renders of the golden → 1 hash
task check green after rebase onto main

Rebased onto main (was 4 commits behind by the end).

Decided, not deferred: the edge list is not sorted. Declaration order is authorial, the goldens encode it, and sorting would churn committed output for a property we don't need — same input still produces same output. Reordering source lines reorders diagram lines, which is correct behavior; the defect was the dropped role, and that is fixed.

Out of scope and filed separately: #29 (pre-existing sanitize gap).

@jbeda
jbeda merged commit 6578d79 into main Jul 26, 2026
2 checks passed
jbeda added a commit that referenced this pull request Jul 26, 2026
Round-1 review fixes for #28.

The ownership fold was wrong in four directions: it fired on two
declarations from the same end that differed only in `ownership` (one
edge silently vanished), it swallowed mutual `owned` with no diagnostic,
it left a reciprocal pair with differing roles drawing one solid and one
dashed line, and `ER()`'s comment plus ADR-0008 asserted a guarantee the
code did not deliver.

Two declarations now merge in exactly two cases: an exact duplicate from
the same end, and a genuine reciprocal (opposite ends, inverse
cardinality, same label, at most one end claiming `owned`). Everything
else draws both lines, so no declaration disappears. The contradictions
the renderer refuses to swallow become lint errors: mutual `owned`, and
a reciprocal pair whose ends disagree on ownership under different roles.

Also:

- Self-relationship rows carry the declared cardinality whole, not the
  substring after the colon, so `0..5:1` no longer renders as `1`.
- Identical self-relationship declarations render one row, not two.
- The prose-role heuristic judges the role as the diagram draws it:
  length, word count, `;` or a trailing `.` — no longer any comma or
  full stop, which false-positived on role lists, accessors and version
  numbers, and no longer blind to a 66-character single word.
- A role raises at most one finding; the undefined-term check waits
  until the role is no longer prose.
- `sanitize` drops backslashes, so `%q` cannot double-escape them.
- ADR-0008, `docs/04`, `docs/06` and the plugin skills describe what the
  code actually does; the `docs/04` snippets match real CLI output.
- `TestProseRoleIsWarning` renamed to `TestADR_0008_ProseRoleIsWarning`;
  the cosmetic `Symmetric` render case is replaced by real coverage of
  the Markdown marker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Joe Beda <joe@stacklok.com>
@jbeda
jbeda deleted the fix/mermaid-self-relationships branch July 26, 2026 03:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant