Repository navigation
fix(resolve): the project's own pin outranks a transitive twin (#39) - #40
Conversation
`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)
Review — PASSHead SHA: Change class: code/config (Go library change — generator resolution law + tests). 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 ( Verdict: PASS ollama-cloud/deepseek-v4.1-flash — action-review. |
marketplace generatepublishes the body of the tree the project PINS, not a transitive twinHead
d0b05ba4b84b33ffc8c2a8e54704e823d36ea582(branchfix/corpus-reads-the-pinned-source, basemainat49143c89f82178dd48d62273431df46d726a47e2),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 generateexited 0 and emitted anopenclaw-layerbody matching neitherthe 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 thefix that makes the emitted body the pinned tree's body.
Where the body came from
Not from the pinned tree at all.
readkinds.gounions every repo in the transitive closure(
CollectEntitiesRemote), then resolves each(kind, family, name)identity to ONE winner(
dedupeEntities->preferEntity,resolve.go).preferEntityranked the candidates by theCalVer tag it could parse out of the fetched cache dir —
if iOK != cOK { return cOK }.The project pins
layer-openclawat a branch (feat/skill-bodies-to-repo-root), and abranch ref carries no CalVer:
tagRankreturnedok=falsefor it, so any transitive tagoutranked it. The closure also reaches the pre-rename
layer-charly-openclaw@v2026.271.2234,whose
openclaw-layer-skillnode still carries the body INLINE — the pre-relocation shape.That node won.
fillSkillFromFilefound noSKILL.mdin that tree at all — it holds none, atany 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 owncomment 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 thecandy:/require:refsthose 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;preferEntitychecks it before the CalVer rank: local-over-fetched, thendirectly-pinned-over-transitive, then highest tag;
candywalksets an entity'sSourceRootto the local root's directory, never to"", so the old empty-string sentinelidentified 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
openclawbodies had moved, not one. The second (openclaw) was shadowed by the samemechanism, from the monorepo-era inline copy of the
openclawcandy still carried by oldsnapshots:
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 arecorrected 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.1114andlayer-openclaw:feat/skill-bodies-to-repo-root— with one clean--outper arm. The arms differONLY in which plugin tree the shim's
go.modreplaces to: the pre-fix commit forout-prefix,this head for
out-fixed. Both rancharly marketplace generateto rc=0, so the controlseparates the two STATES and not two error paths. Everything below ran on the committed tree.
Two
rc=1lines above arediff's "files differ" status (propagated bypipefail), which iswhat each of those diffs is there to show; every gate line reports its own status.
The two-ref closure emits 325
SKILL.mdfiles; exactly two differ between the arms.The bed this repo owns (R10)
The one
SKIPis 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
pinAuthoritysymbol, the fixed view has it, and both carrydedupeEntities. No shared install was used.Rulebook compliance
/charly-internals:git-workflowand/charly-internals:marketplacewere loadedbefore any code change or git write, and this repo's
AGENTS.mdrode 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).--outfile,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).pinAuthorityREPLACES the empty-string sentinel andthe parseable-wins arm;
preferEntitykeeps one ladder rather than two competing ones.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.
resolve.go's header,tagRank's doc andpreferEntity's doc — is corrected in this commit, and names the measured case.tagRankno longer reports alocalflag, no caller can ask for it, and the test that asserted the old law
(
TestPreferEntity_UnparseableNeverDisplaces) is replaced by the two tests that assert thelaw it violated. Both retired identifiers are swept with real output: two
rc=1greps overthe package (
## How tested), one for the test name and one for the removed signature.git status --shortwas read before staging (three modified files, nothing else); onecommit, one push, no force-push, no
mainpush, no hook bypass. The message was amendedbefore the first push, never after.
RED and both guards stay green), and the changed path ran LIVE twice on a real closure, with
the emitted artefacts — the corpus
SKILL.mdbodies — read back and byte-compared against thesource files rather than assumed.
git trees; its one remote dependency (the refs resolver) ran for real against GitHub in both
arms, never mocked.
go build,go test ./... -count=1,go vet,gofmt, thefour-way cross-compile,
charly box validateat the repo root, the repo's owncharly check box marketplace-appbed (9 steps, 8 passed, 1 designed skip, rc=0), and the twolive generator runs.
golangci-lintis not installed on this host, so that arm is not claimedlocally; the repo's CI is the independent arm.
opencharly/opencharly#557) are otherrepos' 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 twosources 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.
Filed issues
must carry the family). Already fixed; this ladder sits on top of it and does not re-open it.
clears. This PR is the reader half that lets a moved body land in the corpus at all.
is a consequence of the same mechanism, not a separate one.
A3 — attribution evidence
The delivered system message in this session reads, verbatim:
Harness
DeepSeek Harness(dsh), modeldeepseek-v4.1-flash. The allowlist gap for these twotokens 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)