Skip to content

[WIP] Content extensibility: catalog seams + content-type registry foundation#28

Draft
codeGlaze wants to merge 186 commits into
developfrom
claude/zen-wright-04xhdz
Draft

[WIP] Content extensibility: catalog seams + content-type registry foundation#28
codeGlaze wants to merge 186 commits into
developfrom
claude/zen-wright-04xhdz

Conversation

@codeGlaze

Copy link
Copy Markdown
Owner

Status: DRAFT / WIP — not for merge. Opened as the coordination + e2e-feedback channel for the content-extensibility work. Please leave testing findings as PR comments (see "Verification ask" below); I'm subscribed and will respond.

What this is

A staged, behavior-preserving start on reducing the multi-file cost of adding homebrew content (the "~8 files to add one builder" problem). Full design/rationale lives in the KB docs added here:

  • docs/kb/content-extensibility.md — design (registry + type-addressed catalogs/grants)
  • docs/kb/content-extensibility-decisions.md — decision audit (D1–D11)
  • docs/kb/content-extensibility-compatibility.md — backward-compat audit + invariants
  • docs/kb/content-extensibility-plan.md — phased, gated implementation playbook
  • docs/kb/content-extensibility-e2e.mdthe live verification checklist (run this)

What's implemented (each behavior-preserving, JVM-gated green)

  • Phase 0extensibility_golden_test.cljc: locks the compat invariants (name→key derivation; saved-character round-trip; boon/invocation option + selection keys).
  • Phase 1/2option_catalog.cljc (by-parent); subraces + subclasses routed through it (proven identical to group-by).
  • Phase 3boption_catalog/plugin-options (catalog read primitive); boons + invocations routed through it.
  • Phase 3a — key-lock guard test for boons/invocations.
  • Phase 4acontent_types.cljc registry (13 plugin-based types) + an audit test that verifies every :spec resolves and every :plugin-key satisfies the orcbrew ::e5/content-keyword contract. Nothing consumes it yet (no runtime effect).

Gate on this branch: lein test 220 tests / 1092 assertions / 0 failures; lein lint 0 errors.

Stacked on the name-keyword fix

This branch includes a merge of feature/name-keyword-fix (the same stable-key fix as #27: identity from :class-key/stored :key not display names, ::plugin-source slot, load-time reconciler) so the catalog work builds on it. The diff will shrink once #27 lands on develop.

Not done yet

  • Phase 3c (remove boon/invocation positional threading) — risky; guarded by the Phase 3a key-lock.
  • Phase 4b–4f (wire the registry into subs/db/events/routes/core).
  • Phase 5 (new builders, e.g. fighting-style / dragonborn lineage).

Verification ask (why this is a draft)

The CI/JVM gate here does not execute the ClojureScript re-frame subscriptions or the running app. Please run docs/kb/content-extensibility-e2e.md in a full dev environment (figwheel + browser + Datomic) and leave a PR comment per checklist item (PASS/FAIL + console/screenshots). Focus: homebrew subrace/subclass/boon/invocation still appear in the builder; the source-suffix preference doesn't orphan keys; existing .orcbrew libraries and saved characters load and round-trip unchanged.

https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ


Generated by Claude Code

codeGlaze and others added 27 commits June 9, 2026 22:04
Add a Content Extensibility initiative documenting the multi-file (8-file)
cost of adding content to the 5e app and a two-layer plan to reduce it:

- docs/extensibility/: handoff, target architecture (registry + type-addressed
  option catalogs/grants), and an ADR-style decisions log.
- docs/kb/content-extensibility-cross-links.md: verified, citation-backed map of
  current parent->child injection sites (subraces, subclasses, boons, invocations,
  draconic ancestries, spells) and their target catalog/grant shape, filed in the
  agent KB per its contribution rules.

Wires both into the docs and KB indexes. Design only; no production code changed.
…agents/develop

Replace the earlier docs/extensibility/ layout with the agents/develop KB structure:
- docs/kb/content-extensibility.md — problem, verified cross-link map, proposed
  two-layer direction (content-type registry + type-addressed catalogs/grants).
- docs/kb/content-extensibility-decisions.md — decisions and rejected options.
- BRANCH.md — branch purpose and handoff, incl. split-commit notes for agents/develop.

Verified-from-code facts are separated from the design proposal per KB rules.
Cross-links existing KB docs and the homebrew-builders issue cluster. Docs only.
- .claude/summaries/2026-06-13-content-extensibility.md: session summary / handoff
  so the analysis and plan survive context loss (matches agents/develop convention;
  force-added since .claude/ is gitignored on this branch but tracked on agents/develop).
- content-extensibility-decisions.md: add a chronological decision audit (how the
  thinking evolved and why we changed course) above the crisp D1-D8 summary.
- BRANCH.md: link the summary.

Docs only.
Audit the persisted formats the redesign must not break (orcbrew/plugins map,
strict-entity characters, localStorage), verified from code with citations:
- Derive the hard invariants (read, plugin-key, selection-key, forward compat).
- Assess the proposal: Layer 1 is compatibility-neutral; Layer 2 is safe only if
  catalogs derive over existing storage and preserve selection/option keys.
- Flag risk surfaces (content-keyword namespace requirement, selection keys as a
  content_reconciliation dependency, silent spec-drop on load) and the existing
  safety nets (import validation, conflict resolution, reconciliation).
- Migration/rollback posture: aim zero-migration, prove with orcbrew + character
  fixtures before/after each step.

Cross-linked from the design doc, summary, and BRANCH.md. Docs only.
Close the loop after the backward-compat audit:
- content-extensibility.md: add the non-negotiable derive-over-existing-storage /
  preserve-keys constraint inline in Layer 2, and a fixture-guard note to the next step.
- content-extensibility-decisions.md: add audit step 8 and D9 (backward compat is a
  hard constraint; target zero-migration; audit formats before planning).

Docs only.
content-extensibility-plan.md: a literal, low-context-agent playbook —
- Golden rules (code branch, one phase per PR, behavior-preserving, never rename
  keys, never commit a red gate, stop-if-ambiguous).
- The exact gate: lein lint / lein test / lein fig:test.
- Phase 0 golden safety test, then phased migration (subraces, subclasses,
  boons/invocations, the registry in micro-steps, then a new lineage type), each with
  files, steps, gate, done-when, and stop conditions.

Cross-linked from design, summary, and BRANCH.md. Docs only.
Add a pure-JVM .cljc golden test (runs under lein test) that locks the backward-
compatibility invariants the upcoming registry/catalog refactor must not break:
- name-to-kw key derivation is stable (every saved character / orcbrew entry
  references content by these keys);
- a saved strict-entity character survives a load/save round-trip idempotently
  with all chosen selection/option keys intact.

Full suite green: 212 tests / 979 assertions / 0 failures. Update BRANCH.md status.

Implements Phase 0 of docs/kb/content-extensibility-plan.md.
…braces

- New leaf ns option_catalog.cljc with by-parent (generalizes the per-type
  group-by used to attach child options to parents). No app dependencies.
- option_catalog_test.cljc pins by-parent identical to group-by.
- Re-point ::races5e/plugin-subraces-map to catalog/by-parent (behavior-preserving).

Gate green: lein test 213/983/0; lein lint 0 errors (7 pre-existing warnings).
Implements Phase 1 of docs/kb/content-extensibility-plan.md.
Re-point ::classes5e/plugin-subclasses-map to catalog/by-parent (same mechanism
proven identical to group-by in Phase 1). cljs-only delegation; lint 0 errors,
JVM suite unaffected.
Implements Phase 2 of docs/kb/content-extensibility-plan.md.
Add option_catalog/plugin-options: the catalog READ primitive, extracting all
options of a content-key across enabled plugins. JVM-unit-tested identical to the
legacy per-type (mapcat (comp vals key) plugins) extraction.

Route ::classes5e/plugin-boons and ::classes5e/plugin-invocations through it —
behavior-preserving, no option/selection keys or function signatures changed
(zero compatibility risk).

Gate green: lein test 214/989/0; lein lint 0 errors (7 pre-existing warnings).
The risky positional-threading removal (3c) is deferred with a guard noted in BRANCH.md.
…on keys

Extend the golden test to build pact boon and eldritch invocation options via the
real .cljc pipeline (pact-boon-options / eldritch-invocation-options with real spell
data) and assert the built-in option keys, homebrew option keys (name-to-kw), and the
:pact-boon / :eldritch-invocations selection keys. This guards the deferred Phase 3c
refactor against silently orphaning saved Warlock choices.

Full suite green: 217 tests / 998 assertions / 0 failures.
… of truth)

Add leaf ns content_types.cljc: a registry describing the 13 plugin-based homebrew
content types (id, type-name, builder-item, spec, plugin-key, route-kw, route-seg,
localStorage key) plus a by-id index. Magic-item and combat are excluded (they don't
use the plugins-map / reg-save-homebrew pipeline).

content_types_test.cljc audits the registry against reality: every :spec resolves
via spec/get-spec, every :plugin-key satisfies the orcbrew ::e5/content-keyword
contract (the orcpub.dnd.e5 namespace requirement saved libraries depend on), and
identity fields are unique. Built from an agent-produced inventory; these checks
auto-verified the inventory's spec/key claims.

Compatibility-neutral: nothing consumes the registry yet. Gate green: 220 tests /
1092 assertions / 0; lint 0 errors. Implements Phase 4a of content-extensibility-plan.
…anch/handoff

New verified context: identity keys must derive from stable ids, never display
names. Folding a plugin-source suffix into a class :name re-ran name-to-kw and
orphaned saved characters; fixed on feature/name-keyword-fix (::plugin-source slot,
key-from-:class-key, load-time reconciler).

Propagate this and the hot-sub caution everywhere:
- compatibility doc: refine the key invariant (stored :key is the contract; name-to-kw
  is creation-time only) + cite the fix branch and reconciler shim.
- design doc: two implementation rules for catalog/grant work (stable-key pass-through;
  layered memoized catalog subs).
- decisions: add D10 (stable-key identity) and D11 (layered catalog subs).
- plan: standing rules + Do-NOT entries.
- BRANCH.md + session summary: coordination note with feature/name-keyword-fix.
- golden test: comment clarifying the name-to-kw assertions don't endorse re-derivation.

Docs/comments only; golden test still 5/25/0.
Bring the stable-key fix under the catalog/registry work so future catalog/grant
phases build on it: identity keys derive from stable ids (:class-key, stored :key)
not display names; option-cfg carries ::plugin-source; a load-time reconciler heals
orphaned spell-selection keys.

Clean auto-merge (the changes are orthogonal: catalog read-seams vs key derivation).
Gate green on the merged tree: lein test 220/1092/0, lein lint 0 errors. The key-lock
golden test passes, confirming the fix does not change boon/invocation/selection keys.
content-extensibility-e2e.md: scoped checklist for a full-environment (figwheel +
browser + Datomic) agent to verify what the JVM-only gate here can't — the cljs test
suite, the catalog read-seams (subraces/subclasses/boons/invocations still appear), the
name-keyword fix behaviors, and backward compat (existing orcbrew + saved characters).
Includes a feedback format. BRANCH.md: record the merge and link the checklist.

Copy link
Copy Markdown
Owner Author

Item 1 — lein fig:test 0 failures / 0 errors: FAIL (literal) / PASS (no regressions)

Summary (this branch):

Test Run
10 failures
3 errors
Totals
150 Tests
888 Assertions

Baseline (origin/develop): identical 10 failures / 3 errors with 132 Tests / 735 Assertions. This branch adds 18 new tests / 153 new assertions, all passing — including the Phase 3a/3b/golden tests this PR cares about (option-catalog-test/plugin-options-matches-legacy-extraction, extensibility-golden-test/{homebrew-key-derivation-is-stable, saved-character-round-trip-is-stable, pact-boon-option-keys-are-stable, eldritch-invocation-option-keys-are-stable, boon-and-invocation-selection-keys-are-stable}, content-types-test/*, plus all 22 content-reconciliation-test cases). No new test regressions.

The 10/3 are pre-existing on develop:

  • character-test/test-character-spec (3 errors) — Unable to resolve spec :orcpub.dnd.e5.character/character (test-build env issue)
  • import-validation-test/{test-apply-key-renames-batch, test-count-non-ascii, test-normalize-text-in-data-recursive, test-dedup-options-in-import-full-pipeline} (8 failures)
  • subs-test/user-stale-user-no-token-still-guarded (1 failure)
  • events-test/save-character-rejects-missing-abilities (1 uncaught: Cannot read properties of null (reading 'call') in entity_spec/entity-val via make_summarycharacter.classes)

Note: lein fig:test only compiles in a headless devcontainer (no browser auto-run). The above was captured by serving target/test/test-runner.html (loading figwheel.main.generated.test_auto_test_runner) over python3 -m http.server and driving it with Playwright's bundled Chromium. Same harness was run against origin/develop's worktree at /workspaces/orcpub-develop to obtain the baseline.

Copy link
Copy Markdown
Owner Author

Item 2 — Homebrew subrace appears under parent race: NOT VERIFIED (fixture gap)

Neither test/duplicate-external-a.orcbrew nor test/duplicate-external-b.orcbrew contains a subrace — both only have full races (:custom-lineage, :ironwrought). I would need to either (a) author a subrace via the Race Builder UI or (b) be handed a richer fixture.

What I can report: after ::e5/import-plugin of fixture A, the Race step does show the homebrew race Custom Lineage injected at the top of the SRD race list (Dragonborn, Dwarf, Elf, ...). That confirms the Phase 1 race-injection seam works for whole races. Subrace verification requires fixture content I don't have.

Covering CLJS tests that DID run and pass: none directly target the subrace seam yet (Phase 0 golden focuses on keys; Phase 3a goldens focus on boon/invocation/pact-boon keys).

Copy link
Copy Markdown
Owner Author

Item 3 — Homebrew subclass appears under parent class: PARTIAL PASS

After importing test/duplicate-external-a.orcbrew, the Class / Level step shows the imported homebrew classes (Artificer, Monster Hunter) interleaved alphabetically with the SRD classes:

Artificer
Barbarian
Bard
Cleric
Druid
Fighter
Monk
Monster Hunter
Paladin
Ranger
Rogue
Sorcerer
Warlock
Wizard

This means the catalog seam for the class list works.

Caveat: the fixture's subclasses (:artillerist, :battle-smith) are tied to :class :artificer where :artificer is also from the same homebrew pack — i.e. the parent class is also homebrew. Verifying that a homebrew subclass appears under a built-in parent class (e.g. a homebrew Sorcerer subclass) requires fixture content or builder authoring I didn't perform. The PR's Phase 2 catalog seam for subclasses is exercised by the passing option-catalog-test/plugin-options-matches-legacy-extraction (.cljc test on this branch).

claude added 30 commits June 24, 2026 10:06
Tight note on why a ::t/spread selection routes to the bag assigner while
everything else keeps the increment UI. Completes the commenting pass over the
ASI spread code (compile/helpers, widget, wiring, authoring all already
documented; full spec in docs/kb/ability-increase-spreads.md). All three ASI
E2Es green after the dead-code cleanup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
A4 (floating ASI) is complete — update the data-shape example to the converged
terse [amount pool] form and replace the long layer-by-layer/bug-by-bug
narrative with a tight summary + pointers (the detail lives in
ability-increase-spreads.md, dropdown-value-coercion.md/D32, D33). Keeps the
roadmap navigable; states the remaining optional items (feat-path
reconciliation, choose-between, explicit-set authoring).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
The open question wasn't the narrow "generic grant-selection vs
per-feature-over-pools" (both are thin compilers to selection-cfg over the same
content-pools primitive, per D30 — neither a parallel engine to delete). It was
bespoke built-ins (years-stable) vs the systematic pool/grant approach.
Resolution: pool/grant is the STANDARD for new/homebrew/cross-silo capability;
stable bespoke constructors are kept where they aren't cross-silo and aren't
hurting, and migrated only opportunistically — never a big-bang rewrite, never
a pool/grant that duplicates a working bespoke path without replacing it.
Commit to the direction, not a rewrite. D29 marked DECIDED in the status index;
roadmap flagged-conflict #1 updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
D34: how we deprecate properly. Two categories — never-released branch
scaffolding gets deleted outright; proven/released code superseded by a new
standard gets #_-struck (the repo's inert-but-preserved idiom; lint-clean,
restorable) under a DEPRECATED note, removed ~3 months after the date (date-based
because the app is a continuously-deployed 0.1.0-SNAPSHOT with no release
cadence). Migration is gated by a characterization test (D29) and tracked.

backfill-ledger.md: the living artifact — migration recipe, the ledger table
(empty so far), and the watch-list (the hardcoded fighting-style grant registry
is the first pool/grant expansion candidate). D29 points to it for mechanics;
added to the KB index.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
Add the same :ability-increases hook to ::bg5e/plugin-backgrounds that races
already have — a background's spread compiles to modifiers + the :asi selection,
merged onto the background map (additive; nil -> {}). background-option already
emits :selections/:modifiers, and the nested :asi selection carries its own
:ability-scores tag, so it routes to the ability tab. This is the 2024-PHB
"ASI via origin/background" capability — the one silo that had no ASI mechanism.
Pure new capability, nothing to deprecate.

cljs harness +3 (161 tests, only the pre-existing user-stale-user): a homebrew
background's spread flows through ::bg5e/backgrounds with the fixed CHA modifiers
and the martial floating slot. Authoring UI + E2E next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
Generalize the race ASI authoring widget (race-ability-increase-choices ->
ability-increase-choices [item set-ai!]) so race and background share ONE form
(no duplication, per D29); the race-builder and background-builder each pass
their own setter. Add it to the background-builder.

test/e2e/background-asi.js (rendered UI, all green): a homebrew background's
spread renders ("Improvement: Background - Tide-Born"), the +2 CHA applies
automatically, the +1 is restricted to the martial pool and lands on the chosen
DEX, and CHA is unchanged by the floating pick. Race authoring E2E still green
(rename regression-checked).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
ability-increase-spreads.md: backgrounds added to the silo list + the hook
citation (spell_subs.cljs:104); note the authoring widget is now silo-generic
(ability-increase-choices). Roadmap A4: race/subrace/background wired and
rendered-UI-proven; remaining = feat-set reconciliation (back-compat reader per
D34), subclasses (rare), choose-between, explicit-set authoring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
New reusable optional-builder-section: a toggle that reveals its body, collapsed
by default (keeps non-standard fields out of the form) but open when content
exists (editing isn't hidden); data only persists if you fill it in. First use:
subclass ASI, which is non-standard for 5e.

Subclass ASI: same one-line :ability-increases hook in ::classes5e/plugin-subclasses
(compile -> modifiers + the :asi selection, merged onto the subclass map;
subclass-option already emits :selections/:modifiers). Authored via the shared
ability-increase-choices widget behind the toggle.

Proven: cljs harness +3 (subclass spread flows through the sub with the fixed
CHA modifiers + martial floating slot); rendered-UI E2E (toggle collapsed by
default, reveals the widget on click, authors [[2 :cha]]). The ASI applies via
the same nested-:asi mechanism already rendered-proven for races/backgrounds.
Race/background E2Es regression-checked green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
ability-increase-spreads.md: subclass added to the silo list + hook + backward-compat
citation; document optional-builder-section (the opt-in toggle) and that classes keep
their own :ability-increase-levels. Roadmap A4: race/subrace/background/subclass all
wired and rendered-UI-proven; the toggle is the reusable opt-in pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
A race and a background each grant a floating +1 martial. The new
test/e2e/multi-container-asi.js asserts they render as two separate
widgets (one breadcrumbed "Race - Tide", the other "Background -
Sea-Marked"), pick independently, and stack on the same stat
(STR 15 -> 17) — each writing to its own container's entity path.

Containment is by the ::entity/path the shared ability-bag-assigner
reads off each selection, so it's orthogonal to programmatic form
generation. Documented in docs/kb/ability-increase-spreads.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
multi-container-asi.js injects the two-silo pack into localStorage; it
never exercised the export path or a re-import into a fresh browser.
The new multi-container-roundtrip.js closes that: a pack carrying both a
race and a background (each granting +1 martial) is exported via the real
button, the browser is fully cleared with localStorage.clear(), the
downloaded .orcbrew is re-imported via the real file input, and both
silos return with their terse spreads intact -- then both ASI widgets
render attributed to their own container and stack (STR 15 -> 17).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
The first version seeded the two-silo pack into localStorage, so the
export round-tripped fabricated data and never exercised the front-end
that produces it. Now the test authors BOTH silos through their real
builder forms -- filling Name/Option Source and driving the actual
<select> dropdowns (the string->keyword coercion layer the original bug
lived in) -- for a race and a background into one pack, then exports,
clears the browser with localStorage.clear(), re-imports the .orcbrew,
and uses both. 15/15 checks: authoring -> export -> clear -> import ->
render/attribute/stack, no seeded data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
Summarizes this branch's work (forked from develop at d42e05d) in the
top-level CHANGELOG.md format so it can be slotted in: the content-
extensibility framework (field-schemas + pool/grant primitive), the
ability-score-increase spread feature built on it, homebrew-source
surfacing, bug fixes, and the characterization-test foundation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
feat-option-from-cfg now reads :ability-increases by shape: a vector is
the cross-silo spread (routed through compile-ability-increases, same as
races/backgrounds/subclasses), a set is the legacy feat format. The
legacy set path (#{:str :con} + the :saves? save-proficiency marker) is
left untouched -- released feat data keeps working verbatim -- while
homebrew feats can now grant fixed/floating/grouped spreads.

Full convergence onto one mechanism is deferred: the spread can't model
:saves?, so the legacy set path is kept (not deprecated), recorded in
the backfill ledger as a deliberate two-readers state.

Tests: 5 new JVM deftests (feat-legacy-set-fixed/choice, feat-legacy-
saves-marker, feat-new-spread-format, feat-new-spread-fixed-only) build
a character through both paths -- 11 tests / 41 assertions green; full
JVM suite 281/1379 green; cljs test build compiles + 162-test harness
unchanged (one pre-existing, unrelated subs_test failure). Docs + KB +
changelog updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
Two orthogonal ways to grant a saving-throw proficiency, both compiling
to the one save primitive (modifiers/saving-throws), no parallel engine:

- Rider: an ASI increment may carry a trailing :save ([[1 :martial :save]])
  meaning "also grant the save on this increment's ability". Fixed ->
  unconditional save; floating -> the save rides the chosen option. Opt-in,
  so the default is bump-only.
- Standalone :save-proficiencies [[count pool]] -> saves independent of any
  bump (different stat, or no bump). Single-stat = fixed; multi-stat =
  choose N distinct from the pool. Reuses the existing choose-a-save
  selection pattern + generic renderer.

Wired into races/subraces/backgrounds/subclasses (one merged hook,
compile-ability-grants) and feats (feat-option-from-cfg). Additive.

Tests: 8 new JVM deftests build a character and read the ?saving-throws
set (fixed/floating rider, rides-the-choice, opt-in default, fixed/
floating/count standalone, crash-safe, rider+standalone composed) +
2 cljs sub-wiring tests. JVM 289/1401 green; cljs 164 tests (one
pre-existing unrelated subs_test failure). Docs + changelog updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
Adds the rendered surface for the two save features:
- a per-row "+ save prof" checkbox on ability-increase-choices (the
  opt-in :save rider; preserved across Amount/To edits),
- save-proficiency-choices, a silo-generic widget (How many + From)
  for the standalone :save-proficiencies field,
wired into the race and background builders and the subclass non-standard
toggle (alongside ASI).

E2E:
- save-grants-authoring.js: the toggle and the standalone widget emit the
  terse data ([[1 :martial :save]], [[1 :mental]]) through the real
  selects/checkbox.
- save-grants-use.js: in the rendered builder the rider's save rides the
  chosen bump (DEX save flips false->true on the pick) and the standalone
  choice (Proficiencies tab) flips the WIS save. Both read off the live
  Saving Throws table.

Existing race-builder-asi / subclass-asi-toggle E2Es still green (the
checkbox is additive). Docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
- roadmap A4: mark feat-path reconciliation and the save tools (rider +
  standalone) DONE; note multi-silo containment and same-stat collapse;
  trim the now-stale "remaining" list.
- README index: ability-increase-spreads entry now mentions the save
  tools, containment, and the feat dual-format reader.
- branch-changelog: correct the feat entry — the spread now models saves
  via the rider, so only the released-data migration remains (ledger).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
Authoring-time guidance only (no mechanics change): save-coverage-warnings
(pure, over a content entry's own data) flags when a creator authors
redundant or overlapping save grants in the feat/race/etc builder they're
working in:
- the same stat granted a fixed save more than once (the duplicate is a
  no-op — saves are a set),
- a fixed save also reachable from a choice pool, or
- two choice pools that overlap (a player could pick the same save twice).
save-coverage-notes renders these inline under the save widgets in the
race/background builders and the subclass non-standard toggle.

This is the builder-scoped version of "help prevent AND explain" — it
only inspects the entry being authored, so it needs no class/character
context and changes no runtime behavior (overlap still collapses safely).

Tests: 5 JVM deftests on the helper (clean / duplicate-fixed / fixed-in-
choice / overlapping-pools / ignores non-:save riders); the authoring
E2E now asserts the note appears only once an overlap is authored. Full
JVM suite 294/1407 green. Docs + changelog updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
save-coverage-ignores-non-save-riders used :ability-increases [[1 :martial]]
+ :save-proficiencies [[1 :wis]] — disjoint pools, so it returned empty
whether or not the :save guard existed (mutation-confirmed: removing the
guard left it green). Switched to the SAME stat on both (a +1 WIS bump with
no :save, alongside a fixed WIS save): correct code returns empty, but if
the :save guard regresses the bump is miscounted as a second WIS save and
the test fails. Now protective (mutation-confirmed red).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
…three)

Three builder surfaces each rendered a list of item problems differently:
simple-content-builder (validate-fields), selection-builder's duplicate/
empty-name warnings, and save-coverage-notes. Same data shape (a seq of
human-readable strings), three visual treatments.

Unify the RENDER into builder-notes [problems {:severity :error|:advisory}]
(:error = blocking "Fix before saving:" list; :advisory = ⚠ guidance) and
route all three summaries through it. Producers stay separate (they compute
different things); selection-builder's per-row highlighting stays bespoke.

App builds; save-grants-authoring E2E (advisory path) and the cljs harness
(simple-content-builder-test, the :error path) green — only the pre-existing
unrelated subs_test failure remains. Spec carried in the builder-notes
docstring and content-extensibility-direction.md (reusable builder widgets).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
Designs out the "false -> nil on repeated clicking" toggle bug at the
source: today comps/checkbox is display-only and every toggle's value is
hand-computed per caller, with no boolean-enforcing element and no :boolean
field type — so a stray (when on? true) or dissoc-on-off writes nil.

render-builder-field gains a :boolean type that is nil-immune BY
CONSTRUCTION: it reads (true? v) (only literal true is on, so nil/absent/
garbage read as off) and writes bf/toggle-next, which returns (not (true? v))
— always a real boolean. No click sequence, stale read, or malformed prior
value can store nil. field-value-pred gains :boolean -> boolean? so a present
non-boolean value is caught by the shared validator (nil/absent = off, fine).

Boolean DATA fields route through this one widget instead of hand-rolled
checkboxes. (Set-membership toggles like the feat :saves? marker and the
:save rider store presence/absence, a different shape, and aren't at risk.)

Tests (builder_fields_test): toggle-next is always a literal boolean from
any prior value; a 50-click hammer starting from nil never yields nil;
the validator accepts true/false/absent and rejects stringy/numeric values.
Full JVM suite 297/1474 green; app builds. Documented in
content-extensibility-direction.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
Reconciles with claude/custom-class-source-error-2k5ykd, which already
solves toggle nil-safety canonically: common/toggle-in / common/toggle-flag
(path-safe, collection-preserving, self-healing) + strip-export-blanks.
Their root cause is deeper than my bf/toggle-next handled — a toggle path
landing on a MAP did (not map) = false, collapsing the collection — so mine
was a weaker, parallel mechanism.

Backed out the parallel bits from the prior commit:
- remove bf/toggle-next and the :boolean case in render-builder-field,
- remove :boolean from field-value-pred,
- remove the toggle-hardening tests.
Nothing on this branch declares a :boolean field, so this is unused
infrastructure — no need to cherry-pick their in-flight helper (which also
converts the ~20 content-prop toggles they asked to leave alone). Left a
note in builder_fields.cljc / render-builder-field so the :boolean type is
added later routing THROUGH their helper, not a fresh parallel fn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
You were right that the branch has a toggle the other branch's sweep won't
cover: the ability-increase-choices "+ save prof" checkbox is new here, so
claude/custom-class-source-error-2k5ykd's toggle conversion won't touch it.

Verified it against the bug class and it's safe by construction — it flips a
boolean (save? = (= :save flag)) and writes a well-formed vector via full
replacement, never (not <collection>), so it can't collapse anything. To
PROVE rather than assert (and to stop it being hand-rolled inline), extracted
the toggle to opt/toggle-increment-save: rebuilds the increment canonically
([amount pool] <-> [amount pool :save]), idempotent and self-healing (a
malformed longer increment normalizes back), never nil.

This is presence-of-a-keyword-in-a-vector, a different shape from a boolean
flag in a map, so it deliberately does NOT use common/toggle-in — noted in
the docstring.

Tests: toggle-increment-save stays canonical / self-heals; a 50-click hammer
never loses amount+pool or goes non-canonical. E2E authoring still green.
(optional-builder-section, the only other branch toggle, is a transient
r/atom — not persisted, not at risk.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
…hesis

Two guardrail follow-ups from the cross-branch design review:

1. Fan-out crash-safety (guardrail 8: seed realistically-messy data). A new
   messy-pak-survives.js E2E seeds a pack with a malformed race (junk ASI/
   save entries) and surfaced a real bug my earlier REPL check had masked
   with try/catch: an entry with a nil pool ([:bad], []) reached resolve-pool
   and NPE'd on (name nil). In the races sub (mapped over every race) that
   crashes the whole pack. Tightened both compilers' guard from filter vector?
   to a shared pool-entry? (vector + numeric amount + keyword/collection pool)
   so one junk entry is skipped, not fatal. JVM messy-tolerance test added;
   the E2E now proves the pack loads and the good race still works, no page
   errors.

2. Convergence note (guardrail 1). Strengthened the builder_fields.cljc note
   so backing out my toggle pieces didn't lose them: the merged :boolean
   primitive must combine BOTH halves — their path-safe traversal + self-heal
   AND my defensive (not (true? v)) read + boolean? validation (collection-
   preservation alone still reads garbage as "on"; leaf-read alone still
   collapses a map) — plus strip-export-blanks and the save-subset-of-load
   guard. Route the eventual :boolean field through that ONE primitive.

Full JVM suite 297/1514 green; app builds; messy-pak E2E green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
…entry? follow-up

New docs/kb/data-safety-layers.md turns the cross-branch toggle-nil review
into a standing design rule: robustness is four layers, not "harden vs
self-heal" — prevent (shape/type), harden (boundary/read), heal (write/
repair, only with an unambiguous target), surface (migration; never silent-
drop meaningful data). Includes the rule that picks between them by
lifecycle, the save-subset-of-load + diagnosable-rejection invariants, this
codebase's mechanisms mapped to each layer, and the two anti-patterns
(silent-drop-on-meaningful-data, speculative-heal). Indexed under Process &
infrastructure alongside verification-discipline.

Tracked follow-up (guardrail 6): pool-entry? skips malformed entries
silently for fan-out crash-safety (correct at runtime) but the authoring
form should report "N entries ignored". Recorded in the doc's follow-ups
section and a marker at the pool-entry? docstring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
compile-ability-increases used mod5e/race-ability for EVERY fixed
increment. race-ability writes ?race-ability-increases, and the character
builder's ability breakdown shows that as the "race" column (with "other"
= total - race - subrace). So a background/subclass/feat fixed +N rendered
as a RACIAL increase and cancelled out of "other" -- a real bug shipped for
backgrounds and subclasses (floating increments were never affected; they
use level-ability-increase).

Make fixed attribution silo-aware: compile-ability-grants takes
:attribution (:race default | :subrace | :general); compile-ability-
increases takes the resolved :fixed-modifier. Non-racial silos pass
:general -> mod5e/ability (neutral, shows under "other"). Wiring:
backgrounds/subclasses :general, subraces :subrace, races default,
feat spread path :general. Totals unchanged; only the source column.

Tests: JVM fixed-asi-attribution-is-per-silo builds a character and asserts
the race/subrace bucket per silo; the two cljs tests that asserted "2
modifiers" for background/subclass (which WAS the bug -- the 2nd was the
race-column write) now assert 1 neutral modifier. Full JVM 298/1521 green;
cljs 164 (only the pre-existing subs_test failure).

Also updates the backfill-ledger: the feat migration's attribution hazard
is now resolved by this fix; only the save-compat option-key hazard remains
(post-merge, needs a key reconciliation + characterization test).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
The attribution fix for FIXED increments didn't reach floating, because
compile-ability-increases decided attribution in two places and only the
fixed branch took the :fixed-modifier. slot-opts (floating) still hardcoded
level-ability-increase, so a chosen floating +N landed in
?level-ability-increases -- a bucket the per-source ability breakdown never
shows. Result: a picked floating ASI raised the total but appeared in NO
column, and a subrace with a floating ASI showed no "subrace" column at all
(reproduced in the rendered builder: STR 15->16 with race 0 / other 0).

A floating pick, once chosen, IS a fixed increase on the chosen ability, so
it should attribute the same way. Route the floating slot options through the
same :fixed-modifier as fixed increments -- attribution is now decided in ONE
place and covers both. Total unchanged; option keys (asi-<idx>-<ability>)
unchanged, so no save-compat impact. ?level-ability-increases has exactly one
reader (the total), so moving picks out of it is total-neutral.

Tests: JVM floating-asi-attribution-follows-the-silo (race pick -> race col,
subrace -> subrace col, general -> not-race). E2E multi-source-floating-
attribution.js: three concurrent floating sources (race +2/+1, subrace +2/+1,
background +1/+1/+1) render as separate breadcrumbed widgets (7 slots, not
merged), and picking the subrace slot makes the subrace column appear (was
absent). Existing floating E2Es (multi-container, save-grants-use, exact-
spread) still green. Full JVM 299/1528; cljs 164 (pre-existing subs_test only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
Three edge cases raised in review:

1. Deselect a floating ASI (rendered-verified, no code change needed): the
   bag-assigner's on-change dispatches :decrease-ability-value when a slot
   returns to "— choose —". Confirmed live: STR 15->16 on pick, back to 15 on
   deselect, and the race-column entry clears (no stale state); changing STR->
   DEX moves the bonus cleanly.

2/3. Multiple feats with floating pools + a static-ASI feat. New JVM test
   multiple-feats-floating-and-static-stay-separate-and-attribute-to-other:
   two feats each with a floating ASI both key their slot asi-0-<ability>, but
   selected together under ONE Feats multiselect they do NOT collide -- each
   STR pick applies and stacks (10+1+1=12) because entity paths disambiguate.
   A static +2 CON feat applies alongside (10+2=12). All feat ASIs attribute
   to "other" (general), none to the race column.

(The rendered multi-feat path is level-gated -- feats need ASI-level slots;
the "Homebrew" override that unlocks unlimited feats is #_-disabled in this
build -- so the multi-feat containment is proven at the entity/build level,
where the actual collision concern lives. The rendering of N nested :asi
selections is already rendered-proven by multi-source-floating-attribution.)

Ran 30 tests / 194 assertions, 0 failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
Correction: the Homebrew override is NOT disabled -- I misread a dead old
global-homebrew def (#_ homebrew-plugin) as "the feature is off." The live
override is the per-section beer-stein mug (character_builder.cljs:644,
toggle-homebrew) that lifts the level/slot restriction on a section.

New test/e2e/multi-feat-floating.js uses it to prove #2/#3 in the rendered
builder: feats are locked (opacity-5) at level 1, the Homebrew mug unlocks
them, two floating-ASI feats render as separate breadcrumbed widgets, their
STR picks do NOT collide despite identical asi-0-* keys (stack 15->18), a
static +2 CON feat applies immediately (13->15), and feat ASIs stay out of
the race column. Corrected the changelog note accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
…rface)

Closes the tracked guardrail-6 follow-up. The compilers silently skip
malformed :ability-increases/:save-proficiencies entries (pool-entry?) for
fan-out crash-safety -- correct at runtime, but it hides data loss from a
creator editing imported/hand-edited content.

- opt5e/ignored-entry-warnings (pure, cljc): counts the entries each field
  would drop and returns human-readable notes.
- views/ability-save-notes (renamed from save-coverage-notes): renders both
  the ignored-entry notes (:error) and the save-coverage notes (:advisory)
  under the ASI/save widgets, via the shared builder-notes.

Tests: JVM ignored-entry-warnings-surface-what-compile-drops (clean/junk/
singular-grammar/nil). E2E ignored-entry-note.js: editing a race whose
:ability-increases is [[1 :cha] "junk" [:bad]] -> the builder does NOT crash
and shows "2 ability-increase entries are malformed and will be IGNORED".
Full JVM 301/1537 green; cljs 164 (pre-existing subs_test only). Docs +
data-safety-layers follow-up marked done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls9kkNatBXcX2b2T1ydzkZ
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