Skip to content

fix(resolve): the project's own pin outranks a transitive twin (#39) - #40

Merged
github-actions[bot] merged 1 commit into
mainfrom
fix/corpus-reads-the-pinned-source
Oct 21, 2026
Merged

github-actions[bot] merged 1 commit into
mainfrom
fix/corpus-reads-the-pinned-source

Conversation

@aitrawog

Copy link
Copy Markdown
Contributor

marketplace generate publishes the body of the tree the project PINS, not a transitive twin

Head d0b05ba4b84b33ffc8c2a8e54704e823d36ea582 (branch fix/corpus-reads-the-pinned-source, base main at 49143c89f82178dd48d62273431df46d726a47e2),
three files, 299 insertions and 50 deletions: the generator's resolution law, its discovery
wiring, and the tests that bind it. #39.

Summary

charly marketplace generate exited 0 and emitted an openclaw-layer body matching neither
the branch file nor the newest tag. The issue left one question open — which code path produces
a body from a node with no content: and no matching file?
— and this is the answer, with the
fix that makes the emitted body the pinned tree's body.

Where the body came from

Not from the pinned tree at all. readkinds.go unions every repo in the transitive closure
(CollectEntitiesRemote), then resolves each (kind, family, name) identity to ONE winner
(dedupeEntities -> preferEntity, resolve.go). preferEntity ranked the candidates by the
CalVer tag it could parse out of the fetched cache dir — if iOK != cOK { return cOK }.

The project pins layer-openclaw at a branch (feat/skill-bodies-to-repo-root), and a
branch ref carries no CalVer: tagRank returned ok=false for it, so any transitive tag
outranked it. The closure also reaches the pre-rename layer-charly-openclaw@v2026.271.2234,
whose openclaw-layer-skill node still carries the body INLINE — the pre-relocation shape.
That node won. fillSkillFromFile found no SKILL.md in that tree at all — it holds none, at
any path — so it took the transitional inline branch and the generator published it, rc=0. The
pinned tree was never read, because the winner was a DIFFERENT ENTITY of the same identity,
chosen by tag rank.

The emitted document is byte-identical to that node's inline content: (e1b94367…, 1778B /
1772 characters), and the issue's own whole-file hashes reproduce exactly on this run
(35a2e3a6… = the branch file, 82e7f941… = the file the pre-fix arm emitted).

The fix

The refs list is the ONE generation input, so the ref the project names DIRECTLY is the
authority
— which is the law the CalVer comparison was only a proxy for, as resolve.go's own
comment already claimed ("'highest tag' is the same thing as 'the refs list's explicit pin'").
It is now carried explicitly instead of inferred from a ref's shape:

  • pinAuthority (resolve.go) holds the generator's OWN roots and the candy:/require: refs
    those roots declare, keyed exactly as candywalk keys a fetched root's Entity.Namespace
    (<repo>:<version>) — so the test is a map lookup on the entity, not a re-parse of a path;
  • preferEntity checks it before the CalVer rank: local-over-fetched, then
    directly-pinned-over-transitive, then highest tag;
  • the local/fetched distinction is decided by root membership. candywalk sets an entity's
    SourceRoot to the local root's directory, never to "", so the old empty-string sentinel
    identified nothing and a local entity could not win at all — that dead arm is deleted.

Transitive refs are not disqualified: a repo reached through the closure still publishes its own
skills. Only a COLLISION on one identity is decided by the project's own pin — which is the case
this file exists for.

Two openclaw bodies had moved, not one. The second (openclaw) was shadowed by the same
mechanism, from the monorepo-era inline copy of the openclaw candy still carried by old
snapshots: layer-supervisord@v2026.239.1300, layer-sway-desktop@v2026.238.2103,
layer-openclaw-full@v2026.238.2103, layer-agent-forwarding@v2026.238.1851,
layer-hermes-playwright@v2026.238.2102. Both are
corrected by this one change, and the whole emitted corpus moves by exactly those two files.

How tested

One scratch corpus root, exactly two refs — pod-charly-hooks:v2026.290.1114 and
layer-openclaw:feat/skill-bodies-to-repo-root — with one clean --out per arm. The arms differ
ONLY in which plugin tree the shim's go.mod replaces to: the pre-fix commit for out-prefix,
this head for out-fixed. Both ran charly marketplace generate to rc=0, so the control
separates the two STATES and not two error paths. Everything below ran on the committed tree.

== head ==
$ git rev-parse HEAD
d0b05ba4b84b33ffc8c2a8e54704e823d36ea582

== the two-ref generate, both arms: the body the pinned tree owns ==
$ python3 /tmp/pm39-charly-tri.py openclaw-layer
source file  bytes=2230 body_sha256=43e60c77e1e4c249 body=1975B (1963c) body==source: True
out-prefix   bytes=2114 body_sha256=e1b943670b4ce5ef body=1778B (1772c) body==source: False
out-fixed    bytes=2369 body_sha256=43e60c77e1e4c249 body=1975B (1963c) body==source: True
rc=0

$ python3 /tmp/pm39-charly-tri.py openclaw
source file  bytes=3149 body_sha256=a0d3964989c27e09 body=2809B (2781c) body==source: True
out-prefix   bytes=2427 body_sha256=66c2c82e5935e5bb body=2018B (1986c) body==source: False
out-fixed    bytes=3285 body_sha256=a0d3964989c27e09 body=2809B (2781c) body==source: True
rc=0

== the same document, whole-file sha256 (the issue's own hashes) ==
$ sha256sum /home/atrawog/.cache/charly/repos/github.com/opencharly/layer-openclaw@feat/skill-bodies-to-repo-root/.agents/skills/openclaw-layer/SKILL.md /tmp/pm39-charly-1792598162/out-prefix/openclaw/skills/openclaw-layer/SKILL.md
35a2e3a6fc2447d533ec8ae2a45184d04afea8db7b3e49886c6618331551b553  /home/atrawog/.cache/charly/repos/github.com/opencharly/layer-openclaw@feat/skill-bodies-to-repo-root/.agents/skills/openclaw-layer/SKILL.md
82e7f941e04a6bd6c7671cf25096b6cac1e2aa48fccaad95f781369667368e31  /tmp/pm39-charly-1792598162/out-prefix/openclaw/skills/openclaw-layer/SKILL.md
rc=0

== the pre-fix body IS the pre-rename node's inline content ==
$ python3 /tmp/pm39-charly-twin.py
# /home/atrawog/.cache/charly/repos/github.com/opencharly/layer-charly-openclaw@v2026.271.2234/charly.yml
INLINE  node=openclaw-full-layer-skill name=openclaw-full-layer family=openclaw  2360B 2344c  sha256=9ee6ebc100e865b9
INLINE  node=openclaw-full-ml-layer-skill name=openclaw-full-ml-layer family=openclaw  1467B 1461c  sha256=3e0a9840e05d1578
INLINE  node=openclaw-layer-skill name=openclaw-layer family=openclaw  1778B 1772c  sha256=e1b943670b4ce5ef
rc=0

== file-vs-source: one hunk, entirely frontmatter + the GENERATED banner ==
$ diff -u /home/atrawog/.cache/charly/repos/github.com/opencharly/layer-openclaw@feat/skill-bodies-to-repo-root/.agents/skills/openclaw-layer/SKILL.md /tmp/pm39-charly-1792598162/out-fixed/openclaw/skills/openclaw-layer/SKILL.md | grep -c '^@@'
1
rc=1

== blast radius over the whole emitted corpus ==
$ diff -rq /tmp/pm39-charly-1792598162/out-prefix /tmp/pm39-charly-1792598162/out-fixed
Files /tmp/pm39-charly-1792598162/out-prefix/openclaw/skills/openclaw/SKILL.md and /tmp/pm39-charly-1792598162/out-fixed/openclaw/skills/openclaw/SKILL.md differ
Files /tmp/pm39-charly-1792598162/out-prefix/openclaw/skills/openclaw-layer/SKILL.md and /tmp/pm39-charly-1792598162/out-fixed/openclaw/skills/openclaw-layer/SKILL.md differ
rc=1

$ find /tmp/pm39-charly-1792598162/out-fixed -name SKILL.md | wc -l
325

== the twin tree carries no body FILE at all — the inline branch was the only source ==
$ find /home/atrawog/.cache/charly/repos/github.com/opencharly/layer-charly-openclaw@v2026.271.2234 -name SKILL.md | wc -l
0

== R5: the superseded rule and the test that asserted it are gone ==
$ grep -rn UnparseableNeverDisplaces . ; echo rc=$?
rc=1

$ grep -rn local bool, ok bool . ; echo rc=$?
rc=1

== the changed package's gates, on this tree ==
$ go build ./...
rc=0

$ test -z "$(gofmt -l .)" && echo gofmt-clean
gofmt-clean
rc=0

$ go vet ./...
rc=0

$ go test ./... -count=1
ok  	github.com/opencharly/plugin-marketplace/candy/plugin-marketplace	1.379s
?   	github.com/opencharly/plugin-marketplace/candy/plugin-marketplace/cmd/serve	[no test files]
rc=0

$ for a in amd64 arm64 arm 386; do GOOS=linux GOARCH=$a CGO_ENABLED=0 go build ./...; echo cross $a rc=$?; done
cross amd64 rc=0
cross arm64 rc=0
cross arm rc=0
cross 386 rc=0

== negative control: the pre-fix ladder, everything else unchanged ==
$ sha256sum resolve.go   # committed
5562a3918acda401378bdf0fe0dbcd2b60476fd69b76274b6c59c0d874ef9fc8  resolve.go
mutated: direct-pin arm removed
$ go test ./ -run 'TheProjectsOwnPin|DirectPinAuthority|LocalOutranksFetched|TransitiveUnparseable' -count=1 -v
=== RUN   TestPreferEntity_LocalOutranksFetched
--- PASS: TestPreferEntity_LocalOutranksFetched (0.00s)
=== RUN   TestPreferEntity_TheProjectsOwnPinOutranksATransitiveTwin
    resolve_test.go:152: the ref the project pins DIRECTLY must displace a transitive twin, even though the twin's tag is the only one that parses
--- FAIL: TestPreferEntity_TheProjectsOwnPinOutranksATransitiveTwin (0.00s)
=== RUN   TestPreferEntity_TransitiveUnparseableStillLoses
--- PASS: TestPreferEntity_TransitiveUnparseableStillLoses (0.00s)
=== RUN   TestDirectPinAuthority_ReadsOnlyTheGeneratorsOwnRefs
--- PASS: TestDirectPinAuthority_ReadsOnlyTheGeneratorsOwnRefs (0.00s)
=== RUN   TestDedupeEntities_TheProjectsOwnPinBeatsATransitiveTwin
    resolve_test.go:233: winner = "/home/u/.cache/charly/repos/github.com/opencharly/layer-charly-openclaw@v2026.271.2234", want the project's own pin "/home/u/.cache/charly/repos/github.com/opencharly/layer-openclaw@feat/skill-bodies-to-repo-root"
--- FAIL: TestDedupeEntities_TheProjectsOwnPinBeatsATransitiveTwin (0.00s)
FAIL
FAIL	github.com/opencharly/plugin-marketplace/candy/plugin-marketplace	0.024s
FAIL
rc=1

$ sha256sum resolve.go   # restored
5562a3918acda401378bdf0fe0dbcd2b60476fd69b76274b6c59c0d874ef9fc8  resolve.go
$ git status --short
(empty above = restored exactly)

== green again on the same suite ==
$ go test ./... -count=1
ok  	github.com/opencharly/plugin-marketplace/candy/plugin-marketplace	1.494s
?   	github.com/opencharly/plugin-marketplace/candy/plugin-marketplace/cmd/serve	[no test files]
rc=0

== binary equals source: the plugin each arm BUILT ==
$ for v in marketplace-cli-92c675c84ecddeaf marketplace-cli-b97ff5bd7d97a72f; do echo "$v"; for s in pinAuthority dedupeEntities; do printf '  %-18s %s\n' "$s" "$(strings -a ~/.cache/charly/plugin-build/views/$v | grep -c -w $s)"; done; done
marketplace-cli-92c675c84ecddeaf
  pinAuthority       0
  dedupeEntities     2
marketplace-cli-b97ff5bd7d97a72f
  pinAuthority       1
  dedupeEntities     2

Two rc=1 lines above are diff's "files differ" status (propagated by pipefail), which is
what each of those diffs is there to show; every gate line reports its own status.

The two-ref closure emits 325 SKILL.md files; exactly two differ between the arms.

The bed this repo owns (R10)

$ charly box validate ; echo rc=$?
charly box validate: OK — checked 1 candy, 1 box, 1 deploy, 7 distros, 7 builders; 0 warnings, 0 errors
rc=0
$ charly check box marketplace-app ; echo rc=$?
Image: ghcr.io/opencharly/marketplace-app:check-marketplace-2026.289.0201
  PASS  check the marketplace command plugin ships a buildable Go module (go.mod + the provider package); the generate → drift round trip is covered by this plugin's own Go tests and this repository's `task skills:drift`, while the live R10 bed (check-marketplace) covers the ARTIFACT they produce — the published marketplace corpus at the pinned commit  [marketplace-module-present]  exit=0
  SKIP  run clone the published marketplace at the pinned commit  [plan:candy:plugin-marketplace:1]  skipped — verify-only mode (mutating step)
  PASS  check the cloned marketplace is the pinned commit, not a stale cached layer  [marketplace-pinned-commit]  exit=0
  PASS  check the Claude Code / Cursor catalog exists and parses, and every entry's source resolves to a real family directory (the corpus shape the marketplace's own drift gate guarantees; the bed re-proves it on the published artifact)  [marketplace-claude-catalog]  exit=0
  PASS  check the Codex / AGENTS catalog exists and every entry's local source resolves  [marketplace-agents-catalog]  exit=0
  PASS  check the kimi + pi manifests ship at the marketplace root (the whole marketplace is one kimi plugin and one pi package)  [marketplace-kimi-pi-manifests]  exit=0
  PASS  check a baked corpus skill carries the generator header and the two-key frontmatter contract (the ai.opencharly.skill label the generator bakes)  [marketplace-baked-skill]  exit=0
  PASS  check per-family manifests carry NO version field (commit-SHA versioning) and the marketplace repository URL  [marketplace-no-version-manifests]  exit=0
  PASS  check the pinned corpus ships its generation input and its dispatcher  [marketplace-corpus-entry-points]  exit=0

9 steps: 8 passed, 0 failed, 1 skipped
rc=0

The one SKIP is the bed's mutating clone step in verify-only mode, by design.

Binary equals source

The generator is an out-of-process command plugin, so each arm BUILT the plugin from the tree
under test and the provenance is read out of the binary itself (## How tested, last fence):
the pre-fix view has no pinAuthority symbol, the fixed view has it, and both carry
dedupeEntities. No shared install was used.

Rulebook compliance

  • R0: /charly-internals:git-workflow and /charly-internals:marketplace were loaded
    before any code change or git write, and this repo's AGENTS.md rode in with the worktree;
    the session opened with read-only reads (the four cited artefacts, then the reader itself —
    skillfile.go, readkinds.go, resolve.go).
  • R1: the anomaly was root-caused BEFORE remediation — the body is not a stale --out file,
    not the pinned tree's inline node, and not a cache artefact: it is a different entity of the
    same identity, selected by tag rank. Every gate above reports zero warnings. Both findings
    inside this diff are fixed here; nothing discovered is left without a home (## Filed issues).
  • R2: one authority, one derivation. pinAuthority REPLACES the empty-string sentinel and
    the parseable-wins arm; preferEntity keeps one ladder rather than two competing ones.
  • R3: no fallback probe, no retry, no suppression, no freshness assertion added to hide the
    emission. The winner is derived from the walk's own provenance, and a ref the project does not
    name simply does not outrank one it does.
  • R4: the prose that stated the superseded rule — resolve.go's header, tagRank's doc and
    preferEntity's doc — is corrected in this commit, and names the measured case.
  • R5: the superseded rule is deleted, not shadowed: tagRank no longer reports a local
    flag, no caller can ask for it, and the test that asserted the old law
    (TestPreferEntity_UnparseableNeverDisplaces) is replaced by the two tests that assert the
    law it violated. Both retired identifiers are swept with real output: two rc=1 greps over
    the package (## How tested), one for the test name and one for the removed signature.
  • R6: git status --short was read before staging (three modified files, nothing else); one
    commit, one push, no force-push, no main push, no hook bypass. The message was amended
    before the first push, never after.
  • R7: the coverage fails without the change (the mutation control above: the two new tests go
    RED and both guards stay green), and the changed path ran LIVE twice on a real closure, with
    the emitted artefacts — the corpus SKILL.md bodies — read back and byte-compared against the
    source files rather than assumed.
  • R8: no live service boundary is crossed. The change is a local generator reading fetched
    git trees; its one remote dependency (the refs resolver) ran for real against GitHub in both
    arms, never mocked.
  • R9: binary equals source, pasted above, for both arms.
  • R10: a Go library change: go build, go test ./... -count=1, go vet, gofmt, the
    four-way cross-compile, charly box validate at the repo root, the repo's own
    charly check box marketplace-app bed (9 steps, 8 passed, 1 designed skip, rc=0), and the two
    live generator runs. golangci-lint is not installed on this host, so that arm is not claimed
    locally; the repo's CI is the independent arm.
  • HC: one cutover, one PR. The wave's other legs (opencharly/opencharly#557) are other
    repos' moves of the same bodies; this diff changes only the reader that mis-selected them.

Change classification

Fix (fix(resolve)), non-breaking. The dedupe's winner can change for an identity that two
sources both publish, and it changes toward the source the project pins — the only behaviour the
corpus contract ever promised. No schema, no CLI surface, no emitted-file layout change.

8	2	candy/plugin-marketplace/readkinds.go
118	28	candy/plugin-marketplace/resolve.go
173	20	candy/plugin-marketplace/resolve_test.go

Filed issues

A3 — attribution evidence

The delivered system message in this session reads, verbatim:

You are an AI agent powered by the deepseek-v4.1-flash model.

Harness DeepSeek Harness (dsh), model deepseek-v4.1-flash. The allowlist gap for these two
tokens is recorded in opencharly/opencharly#538; the footer line itself is unchanged.

Agent: charly

Assisted-by: DeepSeek Harness deepseek-v4.1-flash (fully tested and validated)

`charly marketplace generate` exited 0 and published a pre-move body for a skill
whose source had moved. The dedupe chose the wrong TREE: preferEntity ranked
sources by the CalVer tag in the cache dir, so a project pinned to a BRANCH (or
a commit) could never win against any transitive tag — a tag 23 days older won
— and that winning tree's node still carried the body INLINE, which is why a
node with no `content:` and no matching file still emitted prose.

The refs list is the ONE generation input, so the ref it names DIRECTLY is the
authority. That is now carried explicitly (pinAuthority, derived from the
generator's own roots and the refs their candies declare) instead of inferred
from a ref's shape, and it is checked BEFORE the CalVer rank. The local/fetched
distinction is likewise decided by ROOT MEMBERSHIP: candywalk sets an entity's
SourceRoot to the local root's dir, so the old empty-string sentinel identified
nothing and a local entity could never win.

Measured on a two-ref corpus pinning layer-openclaw at the branch that moves its
two skill bodies to the repo root: before, `openclaw-layer`'s emitted body was
1778B/1772c (from layer-charly-openclaw@v2026.271.2234, the pre-rename inline
twin); after, 1975B/1963c byte-identical to the branch file. The second moved
body, `openclaw`, is corrected by the same change (2018B -> 2809B, likewise
identical to its branch file). Two files of the emitted corpus change and
nothing else moves.

#39

Assisted-by: DeepSeek Harness deepseek-v4.1-flash (fully tested and validated)
@github-actions

Copy link
Copy Markdown

Review — PASS

Head SHA: d0b05ba4b84b33ffc8c2a8e54704e823d36ea582 (branch: fix/corpus-reads-the-pinned-source)

Change class: code/config (Go library change — generator resolution law + tests).
Placement: CORRECT — capability stays in the plugin-marketplace candy; no core/sdk mechanism moved, no alias growth, no new charly/ kit import.

Security screen: PASS — no embedded directives, secrets, egress, guardrail weakening, or supply-chain change; the diff touches only the plugin's resolution code and its tests.

Checklist: All applicable rules PASS or NA. Evidence is internally consistent (299/50 matches the three file hunks; exactly two emitted files differ; mutation control makes the two new tests RED while the guards stay green; live two-arm run proves the changed path executed and the fixed body is byte-identical to the pinned source). R1/R5/R7/R10/R8/R9 satisfied; ADE/SDD N/A (no candy/schema/proto/gen/wire change); DISP N/A (no autonomous destroy/rebuild — check/validate only); COORD footer order satisfied (Agent: before Assisted-by:). Comment thread empty — nothing to disposition.

Verdict: PASS


ollama-cloud/deepseek-v4.1-flash — action-review.

View action run

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