soul.demarkus.io:6309/plans/soul-join.md
soul.demarkus.io:6309/journal/2026-06-22.md draft reader meta

Journal — 2026-06-22

OKF (Google Open Knowledge Format) alignment — step 1: store frontmatter

Google published OKF v0.1 (2026-06-12): an org-knowledge format that is near-identical to demarkus's own model — directory bundles of markdown, cross-link relationship graph, index.md hubs, path-minus-.md as concept identity. Spec: github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md

The one real seam

OKF carries metadata in-body as YAML frontmatter (required type; recommended title/description/resource/tags (a YAML list)/timestamp). demarkus carries metadata out-of-band and treats a body that opens with --- as literal content. So syntactically identical (--- frontmatter), but different data model: in demarkus the on-disk --- block is a store-owned envelope (version/previous-hash/archived + publisher data), it is stripped before serving, and the catalog reads only the publisher keys. Naively publishing a raw OKF file double-wraps the frontmatter and the OKF type/tags never reach the catalog.

Decision — split namespace in store frontmatter (protocol/store/store.go)

Pre-1.0, so changed the storage format. Promote the recognized OKF field names to bare frontmatter; keep everything else namespaced:

  • Recognized OKF keys (type,title,description,resource,tags,timestamp) → written bare. tags serialized as a YAML flow list [a, b] to match spec.
  • Non-spec publisher keys (importance, custom) → keep the meta. prefix.
  • Store-operational (version,previous-hash,archived) → bare, unchanged.

The non-obvious why

The meta. prefix was not decoration — it was the integrity boundary that stopped a publisher from supplying archived: true / version: 999 and forging store state (extractMetadata read only meta.*). Dropping it for the OKF fields means that boundary now has to be enforced by name: added a reservedMetaKeys denylist (validateMeta rejects them) and extractMetadata excludes them. Keeping arbitrary publisher keys under meta. preserves the boundary for the open-ended set — only a closed, known set of OKF names goes bare.

Key property that kept the change contained: the in-memory metadata map stays bare-keyed, so catalog/handler/filter were untouched. Only buildVersionFile (write) and extractMetadata (read) changed. tags round-trips list↔csv, so the map contract ("a,b") is unchanged.

Back-compat: extractMetadata still reads old meta.tags/meta.type writes — live soul.demarkus.io data and existing immutable versions keep working.

What this does NOT do

Vocabulary alignment only. The frontmatter is still stripped before serving and the store's versions/ layout is not an OKF bundle tree — so demarkus does not yet serve or ingest OKF bundles. That is the next layer: an import/export codec (and possibly server-native bundle serving). Because the stored field names are now the literal OKF names, that codec is 1:1 rather than a translation table.

Open scoping question parked: import-only vs round-trip codec vs server-native content-negotiated bundle serving; and where it lives (tools/demarkus-okf CLI vs client/okf package).

OKF — spec + ADR follow-up

Documented the alignment rather than claiming compatibility:

  • docs/SPEC.md §9.4 rewritten to match the code (was already stale — never documented archived or the old meta. keys). Now specifies reserved operational fields, bare OKF field names, tags as a YAML flow list, the meta. prefix for non-spec keys, and that the store block is stripped before serving. §8.1 notes arbitrary publisher metadata + reserved-key rejection. §13 Future Extensions adds OKF interop as a planned codec, not a conformance claim.
  • docs/adr/0002-okf-metadata-alignment.md records the split-namespace decision and the integrity-boundary reasoning.

Framing decision (Fritz agreed): do not assert "OKF compatible / superset" in normative spec text yet — it would overclaim. Accurate framing is two-layer: a single demarkus document's content model is OKF-compatible (the doc itself), but at the system level demarkus is a superset — it builds versioning + hash chain + QUIC + capability auth + LOOKUP around an OKF-compatible document. "The doc is OKF; we built more around it." Earn the compatibility badge with the codec + a conformance check against OKF sample bundles.

Publisher metadata caps raised (10/512 → 50/1024)

Bumped while sizing for OKF producer-defined fields. Key insight: MaxMetaBytes (sum of key+value lengths) is the binding limit, not key count — the 6 OKF fields + importance already use ~7 keys / ~350 bytes, so you hit the byte wall before the key wall. Doubling keys alone would have bought little.

Final: MaxMetaKeys 10→50, MaxMetaBytes 512→1024, maxStoreFrontmatter 1024→2048 (must cover 1024 bytes meta + operational fields + per-line serialization overhead; worst case ~1.5 KB on disk). 50 keys is generous headroom but harmless — the 1024-byte total still bounds real on-disk size. Pre-1.0 is the cheap moment to right-size a wire limit. Constants in protocol.go + store.go; both store and handler validation read the shared constants. SPEC §9.4 updated. Boundary test now ties to MaxMetaKeys+1 with short keys so it isolates the key-count limit and won't rot on future bumps.

Gotcha: tags counted as csv but stored as a longer YAML list

MaxMetaBytes (1024) is validated on the metadata map, where tags is a csv string ("a,b"), but on disk tags serializes as a YAML flow list ([a, b]) — N+1 bytes longer for N tags. So the byte cap undercounted the real on-disk size, and a tag-heavy doc could pass validation then bloat the frontmatter. Adversarial worst case (~460 single-char tags + 49 tiny keys) reached ~2005 of the 2048 maxStoreFrontmatter budget — safe but only ~40 B margin, far thinner than the 1024 number suggests.

Fix: extracted store.SerializedMetaSize(key, value) — counts the value at its on-disk length (tags → formatTagsList) — and used it in BOTH the store validateMeta and the handler size check (they duplicated the accounting; per guidelines, the tricky piece now has one source of truth). The 1024 cap is now an honest on-disk bound, so maxStoreFrontmatter's margin is provable (worst case ~1542 < 2048) and a doc that passes meta validation always fits the frontmatter. Regression test: 400 one-char tags (csv 803 B passes, serialized ~1204 B rejected). protocol.go MaxMetaBytes comment updated to say values are counted as serialized.

OKF codec — first slice: demarkus okf validate landed

New package client/internal/okf (parse primitives import/export will reuse) + demarkus okf validate command (wired into the main.go subcommand switch).

Primitives: SplitFrontmatter (CRLF + EOF-delimiter tolerant), ParseFrontmatter (flat string map; flow [a,b] and block - a lists → csv; tolerates comments; errors on malformed lines), ConceptID, IsReserved. Reuses client/links (goldmark) for link extraction — no new deps.

ValidateBundle checks OKF v0.1 conformance: every non-reserved .md has parseable frontmatter with non-empty type (Error); non-root index.md with frontmatter (Error); broken/escaping internal .md links (Warn, per spec's tolerate-broken-links rule); log.md non-ISO date headings + non-okf_version root-index keys (Warn). Findings sorted (path, message) for stable output. Command: --strict (warns→failure), --quiet; exit 1 on errors.

Deliberately hand-rolled the frontmatter parser (no YAML dep) to match the project's existing manual-frontmatter approach in the store. Limitation: handles scalars/flow/block lists, not nested maps or multi-line scalars — fine for OKF v0.1's flat field set; revisit if a sample bundle needs more. Next slices: import (bundle → world, enforcing the metadata caps with sanitize-on-overflow) then export. Tested against synthetic bundles; should run it against Google's GA4/StackOverflow/Bitcoin reference bundles once import exists.

OKF codec — second slice: demarkus okf import landed

client/internal/okf/import.go + demarkus okf import [--dry-run] [--auth] [--insecure] <bundle-dir> <mark://host/prefix>.

Pure transform BuildImport(root, prefix) []PublishItem (no network, fully tested): per file — SplitFrontmatter, ParseFrontmatter, map OKF fields → demarkus metadata (recognized names are identity), strip frontmatter from body, rewrite bundle-absolute links under the prefix. The command publishes each item via fetch.Client.Publish with expectedVersion -1 (upsert; content-hash dedup means re-import of an unchanged bundle creates no new versions). The target URL's path IS the prefix (via ParseMarkURL) — no separate flag.

Decisions / non-obvious bits:

  • Metadata sanitation, never silent. Invalid keys (e.g. data_steward) sanitized to data-steward with a warning; un-sanitizable or reserved keys (version/archived/previous-hash) dropped with a warning. Added store.IsReservedMetaKey so import reuses the store's reserved set rather than duplicating it.
  • Cap enforcement reuses store.SerializedMetaSize (honest tags-as-list accounting). Overflow drops lowest-priority keys first via a priority ladder (title>tags>type>importance>description>resource>timestamp>producer), each drop warned. Kept caps at 50/1024 per earlier decision; sanitize-on-overflow.
  • Reserved files (index.md/log.md) published verbatim; root index.md's okf_version frontmatter is stripped from the body (else it double-wraps) and kept as okf-version metadata.
  • Link rewriting: only bundle-absolute (/x.md) links get the prefix — relative links resolve unchanged since the tree is preserved. Limitation: empty-text links [](/x.md) aren't rewritten (ExtractWithPositions reports no bracket span); rare, noted.

Deferred: live server round-trip. Server-side acceptance of bare OKF metadata + tags-list round-trip is already covered by handler/store unit tests, and the wire path by other CLI tests, so the marginal risk is low and the setup (TLS + TOML tokens + 2 procs) is high. Becomes natural once export lands: import → export → diff. Next slice: export.

OKF codec — third slice: demarkus okf export + verified live round-trip

client/internal/okf/export.go + demarkus okf export [--auth] [--insecure] <mark://host/prefix> <out-dir>. Completes the codec.

Pure BuildExport(docs, prefix) []BundleFile (no network): per doc — strip the world prefix from path and bundle-absolute links, reattach OKF frontmatter from metadata (canonical field order: type, title, description, resource, tags, timestamp, then producer keys sorted). Synthesizes a type (default "Document") when absent and timestamp from the doc's modified time. tags re-serialized via the shared store.FormatTagsList (one source of truth with the on-disk form). Reserved files written body-only; root index.md re-emits okf_version from the okf-version metadata the importer stashed.

Refactors / reuse:

  • Extracted mapAbsLinks(body, fn) from import's link rewriter; import prepends the prefix, export strips it (boundary-safe: only /pfx or /pfx/...).
  • yamlScalar quotes only when a bare scalar would reparse differently (: , trailing :, #, surrounding space, empty, leading YAML indicator). URLs (no : ) stay bare.
  • Command: enumerateDocs recurses LIST (the LIST verb always lists, never serves index.md — confirmed in handler; versions/ already filtered by ListDir). publisherMeta strips server-owned response keys (status/version/ modified/etag/content-hash/current-version/entries).

Bug found + fixed via the live test: import treated PUBLISH status created as failure — a new-doc publish returns created, only an update returns ok. Now accepts both.

Live round-trip verified (self-signed server on :16309, minted read+publish token): import bundle → on-disk frontmatter exactly as designed (bare tags: [sales, revenue], title, type; meta.data-steward for the sanitized producer key; absolute links rewritten under /vendor, relative preserved) → re-import upserts with content-hash dedup (no new versions) → export → validate reports 0 errors / 0 warnings → exported body byte-identical to original, links restored to bundle-relative, root okf_version round-tripped.

Codec complete: validate + import + export, all three demarkus okf subcommands, shared logic in client/internal/okf, no protocol/server changes beyond the two small exported store helpers (IsReservedMetaKey, FormatTagsList). Next: point validate/round-trip at Google's real GA4/StackOverflow/Bitcoin sample bundles as fixtures; consider log.md generation from VERSIONS on export.

OKF codec — timestamp format handling closed

Closed the gap where incoming timestamp values were passed through unchecked.

  • validate: validateConcept now warns when a present timestamp doesn't parse as RFC 3339 (isRFC3339). Stays a Warn — OKF lists timestamp as recommended, not a hard conformance rule. Refactored validateConcept to accumulate findings (type error + timestamp warn) instead of returning early.
  • export: normalizeTimestamp(declared, modified) re-emits a present value in canonical RFC 3339 (accepts RFC 3339 or date-only YYYY-MM-DD, converts offsets to UTC); empty or unparseable → falls back to the doc's modified (already RFC 3339). So exported timestamps are always canonical.

Required type was already covered: validate errors on missing/empty; export synthesizes type: Document; import warns but stays permissive. Tests added for both timestamp paths. All client tests + pre-commit green.

OKF made a core write-time value: default type on publish

Fritz wanted OKF conformance to be an opinionated, core demarkus value enforced on write. Pushed back on the obvious hard-reject gate: it would break every existing writer (soul.demarkus.io, knowledge broker worlds, journals, ADRs, docs site — all publish typeless markdown via mark_publish), and OKF's own first principle is "minimally opinionated." There was also no existing policy/gate machinery in the codebase to hook into.

Chose opinionated-by-construction: on PUBLISH, when no type is declared, the server assigns Document (protocol.OKFDefaultType) — applyOKFTypeDefault in handlePublish, after extractPublisherMeta. Reserved files (index.md, log.md) exempt; explicit types preserved. Same constant backs export's synthesis (one source of truth). ADR 0003 records it; SPEC §6.4 + §14 updated.

Now every served demarkus document carries an OKF type by construction — maximal per-doc conformance at write time. Still not a served bundle (frontmatter stripped, versions/ layout); full bundle conformance stays an export concern.

Gotcha caught by the existing dedup test: the default is real metadata, so a legacy doc (written without type) republished identically now differs by the added type → one new version, then dedups. Fixed the test to seed v1 with type:Document; documented the one-time bump in SPEC + ADR. Deferred: a strict reject mode as opt-in config/per-world policy (never global default).

OKF type default folded into APPEND too

Extended applyOKFTypeDefault to handleAppend (after extractPublisherMeta), so every write path — PUBLISH and APPEND — guarantees a typed OKF concept.

Noteworthy finding while doing it: store.Append does not merge metadata — it passes the request's metadata straight to WriteVersion, so an appended version's publisher metadata comes from the APPEND request alone, replacing the prior version's. An append with no metadata therefore drops the doc's tags/title/type. With the default applied, a typeless append at least lands type: Document instead of nothing. The broader "append replaces rather than carries forward metadata" behavior is pre-existing and orthogonal — flagged as a possible PR-comment item (should append inherit the prior version's metadata when the request omits it?). SPEC §6.4/§6.6 + ADR 0003 updated to cover APPEND; test added. All suites + pre-commit green; restaged on feat/okf-compatibility.

Shipped — OKF compatibility merged (#195)

Merged to main as squash commit 69c1473 feat(okf): Open Knowledge Format compatibility (#195). Whole feature in one PR: store metadata alignment (bare OKF field names, tags-as-list, 50/1024 caps, reserved-key boundary), the demarkus okf validate/import/export codec, server default type on publish + append, SPEC updates, ADR 0002/0003. CodeRabbit review addressed (7 fixed: export traversal guard, import symlink rejection, LIST decode/cycle hardening, reserved-key read guard, cap re-validation→bad-request, link query strip, test assertions; 1 skipped: SPEC markdownlint, not CI-gated, deferred to roadmap Docs Hygiene backlog). Local feature branch deleted.

/soul-join — managed remote souls + catalog + project binding (built, branch feat/soul-join)

Problem Fritz raised: three demarkus surfaces configurable at once (hand-wired .mcp.json "pure MCP", local memory plugin, knowledge plugin) all expose identical mark_* tools, so the agent gets confused which to write to. Live proof: this project uses demarkus-soul → soul.demarkus.io via hand-wired .mcp.json, while the memory plugin's local backend is down — two "soul" surfaces, one tool name. I conflated them in conversation, which is the bug.

Decisions (Fritz): build /soul-join into demarkus-memory so there's no manual MCP for souls; token storage = wrapper + 0600 file (not inline); maintain a catalog of souls + a per-project binding for routing; detect existing hand-wired entries and offer to adopt. Plan at /plans/soul-join.md.

Shipped (plugin v0.9.0 → v0.10.0):

  • lib.sh: catalog (~/.demarkus/souls, tab rows slug\thost\tinsecure\ttoken-file)
    • binding (~/.demarkus/project-souls, dir\tslug) helpers (register/fields/list/is-registered/bind/binding, all idempotent upserts). Extended publish_gate_scope so the tag-gate fires on joined remote souls too.
  • scripts/soul-join.sh: normalize host (mark:// scheme, reject https→knowledge), derive+sanitize slug from first DNS label, reject reserved demarkus-memory, token→0600 file, register, optional --bind. Best-effort reachability only (QUIC has no HTTP metadata to probe). Test hatch DEMARKUS_SOUL_JOIN_SKIP_BINARIES.
  • scripts/soul-remote-wrapper.sh: self-contained (sources nothing), installed to the STABLE path ~/.demarkus/bin/ so a plugin upgrade can't strand the registered MCP server. Reads the catalog, injects DEMARKUS_AUTH from the 0600 file, execs demarkus-mcp -host … [-insecure].
  • scripts/detect-manual-souls.sh: parses claude mcp list for direct demarkus-mcp+mark:// entries (excludes wrapper-managed ones), emits name/host/insecure/has-token — token VALUE never echoed (transcript safety).
  • commands/soul-join.md, session-guidance "which soul to write to" section, tests/soul-join_test.sh (16 tests).

Bug caught in smoke test: wrapper didn't shift the slug arg, so it leaked into demarkus-mcp's argv as a stray positional. Fixed.

Verified: 16 new + all existing plugin tests pass; pre-commit green; shellcheck clean; wrapper smoke (auth+insecure / no-auth+secure both correct); detect-manual-souls correctly finds demarkus-soul + demarkus-hub and skips the managed local soul.

Open / deferred (phase 2): destination GATE (PreToolUse on mark_publish enforcing the project→soul binding, not just guiding); full auto-migration of a hand-wired entry (currently guided re-join + claude mcp remove); reachability hardening; wrapper auto-reinstall on plugin upgrade. NOT bumped: lib.sh SERVER/CLIENT/TOOLS binary pins — convention says bump per plugin change, but this is plugin-only with no binary release; left for Fritz to decide at release. Fritz commits.

/soul-join follow-ups: binary-upgrade server restart + destination gate

Two more on feat/soul-join.

Bug: binary swap didn't restart the local server (all modes)

ensure_binaries upgrades the on-disk binary but only session-start's managed arm restarted the server (via the .server-version stamp in ensure_managed_server). Two gaps: (1) /soul-join calls ensure_binaries for demarkus-mcp and could swap the SERVER binary mid-session without restarting — a bug I introduced; (2) reuse mode is never restarted by design, so a binary bump leaves it on the stale binary (this is exactly the down-server-at-content symptom).

Fix: ensure_binaries sets a DEMARKUS_BINARIES_REPLACED global only when it actually swaps a binary. New restart_local_server_on_upgrade (lib.sh) reads it, loads the config in a subshell (no var clobber), and restarts the configured server with its OWN recorded root+port. Relaxes the old "never restart a reuse server" policy per Fritz's ask ("find the local server config and restart with the same config"): for an externally-started/reuse server (no .pid of ours) it stops the process found at the root first so the respawn binds the freed UDP port, then ensure_managed_server. Wired after ensure_binaries in both session-start and soul-join. Non-fatal (warns, never aborts the caller). 5 tests stubbing ensure_managed_server/pid_of_server_at_root (incl. a real stop-external-then-respawn using a sleep stand-in).

Phase 2 shipped: destination gate (binding enforced, not just guided)

New PreToolUse hook hooks/dest-gate.sh on mark_publish AND mark_append. When a project has a binding (project_soul_binding), a write to a different soul is a misroute → denied at write time. Scope is narrow + fails open: acts only when the tool targets a soul this plugin routes (soul_target_iddemarkus-memory for local, slug for a joined remote, empty for knowledge/ unrelated → deferred) AND a binding exists. No binding → defer, so the vast majority of projects see zero change. Strictness block (default — agent self-corrects to the bound soul, no human interrupt) | ask | warn, via DEMARKUS_MEMORY_DEST_STRICTNESS / plugin-memory.dest-strictness. Separate axis from the tag-gate strictness. 11 tests.

Design note: default block (not ask) is deliberate — an explicit /soul-join binding means writes belong to that soul, and for an agent a deny + reason triggers a silent retry to the right server, which is less disruptive than asking the human every time. Relaxable per-repo/env.

Session-guidance updated: "which soul to write to (catalog + binding)" now states the binding is enforced. Full suite green (112 tests across 12 files), shellcheck clean, pre-commit green. Still plugin-only; binary pins untouched (Fritz's call at release). Phase-2 remainder: full auto-migration of a hand-wired entry; append-metadata carry-forward is an orthogonal pre-existing item.

/soul-join — reuse-restart softened + #197 pin-automation reconciled

Two corrections to the above.

Reuse restart: only start a DOWN server, never kill a healthy one

Reversed the earlier "stop the external server then respawn" behavior (Fritz: "only start a down reuse server, never kill a healthy one"). restart_local_server_on_upgrade now branches by ownership: OUR managed server (our .pid) → ensure_managed_server restarts it; a server we don't own (reuse/external) → if healthy, leave it untouched and warn it's still on the old binary (user restarts on their terms); only if DOWN do we start it on the new binary with the recorded config. No kill path at all now — simpler and safer. Test renamed to reuse_healthy_external_left_alone (asserts not-killed AND not-respawned); managed test now writes a .pid to exercise the owned branch.

Binary-pins note was moot — #197 already automated it

My "binary pins untouched, Fritz's call at release" caveat is obsolete: #197 (chore(plugin-memory): bump to OKF release + automate future pin bumps, already in main and this branch's base) added .github/scripts/bump-plugin-pins.sh + .github/workflows/plugin-pin-bump.yml — a daily/on-release workflow that opens a PR bumping SERVER/CLIENT/TOOLS pins to the latest releases and the plugin patch version to match. So NOT hand-bumping pins was correct, and it's now mechanized. The old conventions.md rule "bump plugin pin versions on every update" is superseded by that automation. My manual 0.9.0→0.10.0 minor bump (for the feature) doesn't conflict — the workflow no-ops when pins already match latest (they do: 0.18.0/0.13.0/0.2.0). Branch verified current with main (nothing ahead), full suite 112 green.

/soul-default — standalone default-binding command (built)

Fritz wanted a way to set this project's default write target without re-running /soul-join. Until now the binding (~/.demarkus/project-souls) was only ever written as a side-effect of /soul-join --bind, so re-pointing an already-joined repo meant a full re-join. New /soul-default: list the catalog, pick one, save it. Naming his (soul-default, clearer than "bind").

Design question he posed and we settled: "is the plugin smart enough to use another server, or does that need a tool?"neither a router nor a dispatch tool. Routing is already expressed by which mcp__<slug>__mark_* the model calls; each joined soul is its own MCP server. The plugin's job is only the default binding + the dest-gate guardrail. A mark_publish_to(target,…) tool would force the local soul's server to proxy to remotes — duplicating MCP's per-server routing and breaking "each soul = its own server." So: the catalog is the discovery surface (agent reads ~/.demarkus/souls to know what exists), the binding is just the default, reads/one-off writes to any other joined soul go direct to its tools. Confirmed reads are already ungated (dest-gate fires only on publish/append).

Built (follows the soul-join script+command+test pattern):

  • lib.sh: local_soul_present, soul_catalog (unions local managed soul + remote rows → id\ttier\thost\tinsecure — fulfills the soul_catalog() promise the SOULS_REGISTRY header comment already made), is_catalog_soul (validates a slug is actually joined before binding, so a binding can't point at an unjoined soul).
  • scripts/soul-default.sh: --list --bind DIR (catalog, current default marked *) and --set SLUG --bind DIR (validate → bind_project_soul). Joins nothing, touches no MCP config/token, reuses the awk upsert so only this project's row changes.
  • commands/soul-default.md: list → AskUserQuestion → set.
  • tests/soul-default_test.sh: 10 tests.

Verified: soul-default 10/10, soul-join 16/16 (lib changes safe), dest-gate 11/11; shellcheck clean (-x).

Gotcha — the "one-off override" premise is false in current code

The original /soul-default management prompt (and earlier conversation framing) claimed "the destination gate honors explicit per-call targeting, so a one-off write to another soul needs no file edit." Not true today. dest-gate scopes to any joined soul (is_registered_remote_soul) and compares soul_target_id to the binding — so a deliberate mcp__hub__mark_* while bound to soul is denied (strictness block). There is no override path in the code. Fritz scoped this build to list+select+save only ("that is it"), so the override was deliberately NOT built. Two ways to make the claim real if revisited later: (a) a gate-honored one-shot signal (e.g. a ~/.demarkus/dest-override sentinel the gate consumes once), or (b) default the gate to warn. Parked. The stale "honors per-call targeting" wording in the management prompt should be corrected when the override is actually built.

NOT committed (Fritz commits). Binary pins untouched (plugin-only; #197 automation no-ops since pins already match latest).

/soul-default — shipped (PR #202 merged to main)

Follow-up to the build entry above. Bumped plugin 0.10.1 → 0.11.0 (plugin.json + marketplace.json), branch feat/soul-default, PR #202.

CodeRabbit (ASSERTIVE) flagged 3, all valid, all taken:

  • lib.sh soul_catalog masked read errors as empty (Major). Was awk … 2>/dev/null || true — a present-but-unreadable SOULS_REGISTRY would print nothing and the CLI --list would report EMPTY ("run /soul-join") when the real fault is permissions. Fix: [[ -e ]] || return 0 (absent = no remotes) then [[ -r ]] || return 1 (unreadable = propagate), awk unguarded. Note this makes soul_catalog stricter than its sibling list_remote_souls (still fails-open) — accepted, since soul_catalog feeds the user-facing list.
  • soul-default.sh --set accepted a flag as the slug (Minor). --set --bind X took --bind as SLUG then failed later with a misleading "unexpected argument". Fix: reject -* values (slugs are sanitized [a-z0-9-], never lead with -) → direct "requires a soul slug".
  • No CLI test for the local branch (nit). Added test_set_local_when_configured (end-to-end --set demarkus-memory via seed_local) + test_set_rejects_flaglike_slug. 12 tests now.

Title check also flagged: corrected PR title scope toolsplugin-memory (repo convention) + grammar via gh api -X PATCH (plain gh pr edit hit the Projects-classic GraphQL deprecation bug). Skipped CodeRabbit's "docstring coverage 50%" pre-merge warning — bot heuristic miscount; every function carries a comment block per repo convention.

Merged squash ce700d8. Verified post-merge: all three fixes present on main, cache 0.11.0 byte-matches main, /reload-plugins picked it up, /soul-default live (this repo bound to soul). Fritz merged (I don't commit/push).

Release cleanup — pruned GitHub releases 39 → 8, kept all tags

After the OKF set shipped (protocol 0.8.0 / server 0.18.0 / client 0.13.0 / tools 0.2.0), cleaned up the accumulated patch-release noise on GitHub.

Retention policy chosen: latest minor + one prior anchor per module. Kept 8: client v0.13.0+v0.12.43, server v0.18.0+v0.17.19, protocol v0.8.0+v0.7.13, tools v0.2.0+v0.1.38. Deleted the other 31 (client 0.12.34–42, server 0.17.10–18, tools 0.1.25–37) — all CI auto-bump patch releases.

Why it's safe (checked before deleting)

  • install.sh resolves the latest release per module (fetch_latest_version greps the releases API for the newest <component>/v* tag) — old releases serve no install purpose.
  • The plugin pins the current OKF set (lib.sh SERVER 0.18.0 / CLIENT 0.13.0 / TOOLS 0.2.0) and bump-plugin-pins.sh auto-tracks latest — never references old.
  • Internal builds use replace … => ../protocol|client with v0.0.0 placeholders (go.mod), so they do NOT resolve published tags — deleting tags wouldn't even break the monorepo's own builds or the next release's changelog base (kept the immediately-prior anchor per module).

Key decision: delete the RELEASE, keep the TAG

gh release delete <tag> --yes without --cleanup-tag. Rationale — tags cost a few bytes and deleting them adds real risk for zero space saving:

  • Go proxy/checksum poisoning (the nasty one): these are public Go modules; any fetched version is cached immutably by proxy.golang.org + sum.golang.org. Deleting the git tag doesn't purge them, AND if a deleted tag name is ever reused on a different commit → consumers get checksum mismatch / SECURITY ERROR. Never-reuse is the only safe rule.
  • External go get module@vX breaks (source side) for versions not already proxied.
  • Tag deletion is non-durable anyway — a later git push --tags resurrects them.
  • Loses git checkout <tag> reproducibility + provenance link for Docker images.

So: cleaner Releases page, every version still rebuildable from its tag via GoReleaser. Verified: 8 releases remain; spot-checked deleted-release tags (client/v0.12.34, server/v0.17.10, tools/v0.1.25) still present on origin; 192 remote tags untouched. Gotcha noted: old prebuilt binary ASSETS are gone (no download-a-prebuilt rollback to a deleted version), but rebuild-from-tag works.

Related documents

trail
  1. soul.demarkus.io:6309 graph: soul-join
  2. 2026-06-22