feat(skills): let a qualifier address one of several colliding package skill keys - #7105
feat(skills): let a qualifier address one of several colliding package skill keys#7105rnoack1 wants to merge 1 commit into
Conversation
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of I have everything I need. Assembling the review. First-Principles-Verdict: CONCERNS The stat-bound qualifier and its pinned-descriptor apparatus defend a persist-time window that the path-based Not justified as shipped
What this change shipsInventory (10 items) — 6 justifiedIntent: make each of several package bundles vendoring a skill at the same relative path individually listable and openable, instead of both silently vanishing — a FIX of the catalog↔resolver invariant (added tests fail on base), shipped as an additive key grammar.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] c6f9968 |
Design Review (Fable 5, fork) — 🟡 CONCERNSDesign-level review of The design gate is complete: I read the full PR description, the backend hunks ( Design-Verdict: CONCERNS Sound, exhaustively reasoned grammar fix; the one trade a human should ratify is the unconditional hardlink 404 on dedup installs. WatchThe Suggestions
[DESIGN-REVIEWED] c6f9968 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsI've reviewed the full diff: the core security-critical Python ( I actively tried to break the changed code:
No candidate needed falsifying (the discovery pass produced none), and my own extension pass surfaced nothing meeting the (a)/(b)/(c) bar at 80+. No findings. [OPUS-REVIEWED] c6f9968 |
21211c5 to
80e872f
Compare
80e872f to
6b7408c
Compare
6b7408c to
d60c1a2
Compare
d60c1a2 to
d9bd9e4
Compare
|
Verified fixed at head Your mechanism was real, and I reproduced it before believing the fix, so this is not a rebuttal. Fail-first check. I built both layouts you named on a real filesystem and ran them through the actual enumerate/resolve seam. Both pass at this head. Because "it passes" proves nothing on its own, I then reintroduced the defect — widening the resolver's set to the nested-leaf tier and deriving the qualifier from that set again — and got your exact result back: So the probe does detect the drift; the tree no longer has it. What closes it. I took the stronger of the two options you offered — one shared helper rather than re-synchronising two derivations:
Why the class is now unreachable rather than merely re-synchronised. A qualifier is Tests. Nothing here is owed back to you — flagging it only so the changes-request is not left blocking work that is done. |
|
Thanks — this was correct at the sha you reviewed ( Your required change, taken on the arm you offered second: one shared helper. The collision set is now computed in exactly one place, The other half of the asymmetry is gone too. The resolver's derivation input is the exact tier only — The mechanism you identified is now unreachable rather than merely fixed. A qualifier is no longer a distinguishing path segment; it is Measured on your two layouts at this head, both reproduced verbatim from your review:
On the test you challenged — And your last point was still live, in the direction you did not expect — so thank you for it. The over-broad One thing I have deliberately not resolved on my own, in case you have a view: the invariant is now absolute for every key the catalog offers, but some paths are deliberately not offered — a rel containing the reserved separator, a glob metacharacter, Additional control run since posting, at the same head. Rather than rest on your two
113 layouts, 0 violations. The middle column is the vacuity guard and is the reason the |
|
Re-verified at head The two collision sets are now one. The other half of the asymmetry is gone as well. The resolver's derivation input at The mechanism you identified is unreachable rather than patched. A qualifier is no longer a distinguishing path segment; it is Measured on your two layouts at this head:
On the test you challenged — One note from the rebase, since it touches your area: resolving against the read-only-territory change kept both write guards rather than either replacing the other — |
|
You were right at the commit you reviewed, and all three parts are now fixed in code rather Why the mechanism you described was real. At 1. The two sides now derive from one set, restricted to the exact tier. You offered two The qualifier also no longer depends on that set at all. Your cited 2. The test now carries both fixtures you named. 3. The docstring sentence you quoted is still there, and is now true. I did not take your third arm (weaken the description to name layouts where an enumerated key Measured at this head, not just read. Both layouts from your review behave correctly: the |
|
You were right at the commit you reviewed, and this is fixed in code rather than answered in Why the mechanism you described was real. At 1. One shared derivation, restricted to the exact tier. You offered two arms and both are in. The qualifier also no longer depends on that set. 2. The test carries both fixtures you named. 3. The docstring sentence you quoted is still there and is now true. I did not take your third arm (weaken the description to name layouts where an enumerated key does Measured at this head, not just read. Both layouts from your review behave correctly: the |
|
You were right at the commit you reviewed, and the remedy you asked for is the one that Why the mechanism was real. At One collision set, not two. You offered "derive the collision set in one shared helper
Both admissions you name are addressed at the call site rather than inside the helper. The I tried to reproduce the mint-then-fail on both shapes you named, and it does not. A
That clean result only means something because the audit is proven able to fail: re-deriving On the description. The sentence you quoted — "When no segment tells the colliding roots |
|
Item 1 — "enumeration still emits qualified keys the resolver refuses" — is fixed at
Neither side computes membership itself, so there is no second collision rule that could disagree with the first. Both The qualifier itself is The invariant is enforced rather than asserted: One correction I owe you in the other direction, found by a review lane on a later sha and now stated in the code: resolution is deliberately wider than enumeration, not its exact inverse. The resolver also accepts a bare leaf name through its nested tier, so a rel the catalog lists at its full relative path stays reachable under the leaf too. The direction your item names — a key offered that resolves to nothing — is the one that is closed and tested; I have narrowed the claim in |
|
Both required changes are in place at head The collision set is derived in one shared helper that both sides call. The qualifier is no longer a path segment. It is a per-root identity digest ( The invariant test now carries both fixtures you named. I checked that the test can actually fail for your defect, rather than assuming its absence. Reintroducing the mechanism — making the derivation set-dependent while feeding the resolver and the enumerator different sets — makes that test fail with One clarification on the invariant's wording, which your review was right to press. The guarantee is one-directional, and the text now says so: every key enumeration emits resolves. The converse does not hold, because resolution deliberately also accepts a leaf-name key through its nested tier, so a rel listed at its full relative path stays reachable under the bare leaf. The "inverse" phrasing is removed from |
|
Both required changes are in place at head The collision set is derived once, by a helper both sides call. The re-derivation hazard is removed rather than patched. The qualifier is a per-root identity digest, a function of the root's own canonical path alone. Because it does not depend on what else is in the candidate set, a wider set at resolve time cannot shift a genuine holder's qualifier deeper or to nothing — which is the mechanism the report rests on. Relatedly, the resolver's derivation set is the EXACT tier only; the nested-leaf Both layouts from your report are now permanent tests, not incidental fixtures. I checked those tests can actually fail for your defect rather than passing vacuously. Reintroducing the mechanism — making the derivation set-dependent while feeding the resolver and the enumerator different sets — makes the nested-leaf test fail with One clarification your review was right to press: the invariant is one-directional and the text now says so. Every key enumeration EMITS resolves; the converse does not hold, because resolution deliberately also accepts a leaf-name key through its nested tier. The "inverse" phrasing is removed from |
|
Re-verified at head Your Required change offered three routes. The code takes the one you named as preferable, and the two specific mechanics you asked for are in place as well:
The Separately, the re-derivation hazard is removed rather than worked around: the qualifier is a per-root identity digest, a function of the root's own canonical path. Because it does not depend on what else is in the candidate set, a wider set at resolve time cannot shift a genuine holder's qualifier deeper or to nothing — which is the mechanism the report rests on. "Then extend I checked those tests can actually fail for your defect rather than passing vacuously. Reinstating the mechanism — making the derivation set-dependent while feeding the resolver and the enumerator different sets — fails them with On the wording your title flagged: the invariant is one-directional and the text now says so. Every key enumeration EMITS resolves; the converse does not hold, because resolution deliberately also accepts a leaf-name key through its nested tier. The "inverse" phrasing is gone from the source (0 occurrences) and the description no longer claims a two-way absolute. |
UX Review (Fable 5, fork) — 🟡 CONCERNSUX-level review of The screenshots the PR adds under UX-Verdict: CONCERNS Removing a warned chip silently also adds the held pick — the compound action lives only in a tooltip and a bare "+ name" fragment. Watch
Evidence gaps
Suggestions
[UX-REVIEWED] c6f9968 |
|
Both claims from your review verified at the current head "The path is omitted and logged" — "The invariant is asserted by resolving every enumerated key" — On the derivation asymmetry itself, which was the substance of your finding: the collision set is now computed by one shared helper, One related hardening landed on this head after a separate review pass, and it is worth naming because it removes the last route into the same class of rebind: a qualified key is now answered with the candidate whose identity was already checked ( |
|
Verified at head The mechanism you describe — an extra holder that shares a qualifier segment shifting a genuine holder's derived qualifier deeper or to
Your two structural asks are also both in place:
And the test you asked for is extended: Separately, and not something you raised: the qualifier is now re-derived against a pinned root descriptor ( |
|
Fixed at head What was actually breaking it at the previous head. Resolution had grown a platform precondition: the qualified path opened a pinned directory descriptor and returned The fix is a subtraction, not a patch. The whole pin path is gone at this head, and qualified keys resolve by the same by-name digest re-derivation everywhere:
On your original mechanism — two different collision sets. That specific shape does not reproduce here, and the reason is worth stating because it is not what your review assumed:
The test you asked for. Also dropped in the same commit, since it existed only to observe the pin path: the success-read audit line on the package detail read. The refusal audits are unchanged. |
|
Re-verified at head Your item — description/code mismatch on the enumeration invariant: satisfied at this head. The mismatch you found was real: the description claimed the invariant absolutely while the
One thing your review did not name, corrected in the same commit. The description also Also in this commit, from a CI failure rather than from review: the read-time identity check |
|
Verified at head The two sides now derive the collision set in one function. The nested-leaf tier no longer joins the derivation set. The resolver passes Symlink aliases are deduplicated inside that shared function. Your reproduction is a permanent test rather than a fixed case. The description no longer claims the invariant is absolute. It now states the invariant holds "in the ONE direction it claims", which is the narrower statement your review asked for. Your closing note about the mechanism sentence being over-broad was also right, and worth recording: an extra holder only poisons the derivation when its own path carries the segment the enumerator picked, which is why the three-root test with disjoint segments passed while the guarantee was false. Both fixture roots above are built to carry the colliding segment specifically so that the test exercises the real mechanism rather than the general shape. |
|
Re-verified at head The two derivations are one function. The resolver's derivation input is the exact relative path only. It passes Symlink aliases are deduplicated inside that shared function, via On the mechanism specifically — worth stating because it is what makes the whole class unreachable rather than merely fixed at these two sites. A qualifier is I checked that empirically rather than asserting it: re-adding the nested-leaf tier to the resolver's derivation input leaves And the test is not passing vacuously. Both layouts from your report are now permanent fixtures in it — The description no longer claims the invariant is absolute — it states that it holds "in the ONE direction it claims", which is the narrower wording your review asked for. Your closing note was also right and is worth recording: the mechanism sentence was over-broad, since an extra holder only mattered when its own path carried the segment the enumerator had picked — which is why the original three-root test passed while the guarantee was false. Both fixtures above are built to carry the colliding segment for exactly that reason. |
|
@bolichen97 — this is addressed at head The two collision sets are now one derivation.
The resolver's input is the exact relative-path glob only. There is no union with the nested-leaf tier, which was the specific widening your required-change note named. The qualifier no longer depends on which roots are in the set. Reproduced both of your layouts at this head, asserted the way your review specifies — resolving every enumerated key and comparing it to the copy it was enumerated for:
No key resolves to The description text you quoted is gone. The sentences about "no segment tells the colliding roots apart" and a "qualifier drawn from a shared segment" have 0 occurrences in the current body, and it now states the opposite explicitly: membership of a segment in a root is not the contract, because a replaced root that still carries a segment would otherwise keep resolving. Pinned so it cannot regress. The invariant test carries your deeper |
|
Verified at head Your reproductions resolve at head. I built both layouts exactly as written — The mechanism has moved since the revision you read. The qualifier is no longer a path segment: But your point about the test was correct, and it was still correct at head. You wrote that the invariant test does not actually pin the guarantee. The fixtures had since gained both shapes — a nested-leaf root and a symlink-alias root — yet I measured whether the test bites, by applying the two drifts you name:
Both invisible. The reason is the digest: widening the holder set cannot re-spell a key the enumerator did mint, so "every enumerated key resolves" stays true. What widening actually breaks is the other direction — the resolver starts accepting qualified keys the catalogue never minted, and resolution stops being enumeration's inverse, which So the fix is to assert that direction, which nothing did: Suites at this head: 127 on this file plus 122 under a Windows-behaviour simulation, and prompts 261 / skills 150 / skill-browser 83 / hooks 167 unchanged. Thanks for the layouts — the forward invariant looked airtight and the mutation run is what showed it was load-bearing in only one direction. |
|
You were right, and about the sha you reviewed. Both reproductions are fixed at head Your mechanism was the code at What is at head instead:
Both of your layouts, run at this head. Layout (a) — root1 On the test you flagged as not pinning the invariant — that is fixed too. Your narrowing was the useful part: because the extra holder only poisons the derivation when it shares the picked segment, a three-root fixture with disjoint segments passes while the guarantee is false. That is why the two new roots share This head also fixes an unrelated issue in the same area you may want to glance at: an unqualified |
|
@bolichen97 — settled at head One derivation, both callers. The mechanism is no longer expressible. At Your reproductions are tests now. The three-root nested-leaf layout and the symlink-alias One thing your review prompted that is worth flagging separately: reserving |
|
@bolichen97 — I measured this rather than reasoning about it, and your headline turned out The invariant holds at this head, including on your layout. I built the three roots you The third root's nested leaf is keyed at its own full relative path, not folded into the Two further layouts came out the same: two roots differing only above the rel, and both Why the mechanism no longer applies. Your quoted qualifiers are Your headline was still correct, one layer down. "Description / code mismatch" held at The invariant text itself I left alone: it already claims one direction only and states that |
|
Verified at head One shared collision-set helper, called by both sides. Dedupe by resolved file identity, inside the helper. The derivation no longer keys on a path segment. Both layouts you reproduced on are pinned as fixtures. In Two items on this change are still open pending a maintainer decision, so this is not a claim that the whole change is clear: the scope of the hardlink read-refusal — whether it should extend to the by-name read path the non-package skill territories use — and the unresolved-key filtering in the skills editor. |
|
Re-verified at head 1. The enumerator and the resolver no longer derive from two collision sets. There is one computation: The other half of the asymmetry is closed too. The resolver's derivation input at 2. You were right that enumeration could still offer a key the resolver refuses — and the mechanism you identified was not the only one. A second, independent instance was live until this head: 3. The invariant is asserted by resolving, not by prefix. 4. On the description mismatch your title named. One claim was still wrong at the head you reviewed and for some time after: the body said the mapped chip fell back "to the first 8 hex", while the code refuses any digest fallback at Two things on this change are still open rather than settled, so this is not a claim that the whole diff is clear: the scope of the hardlink read refusal on |
|
Thanks for the two reproductions — I ran both as tests against the head rather than reasoning about them, and they are now permanent regressions. New head Fixed in code. Your third point was right, and it was the part with a real gap: both root shapes existed only inside a six-root fixture, and I measured that fixture blind to the drift. On the mechanism. At this head the qualifier is not a path segment. Two supporting coordinates. The qualified branch's derivation input at That is also the shared-helper option you preferred, and it is what the code does: one definition at Where the drift is pinned. |
|
Verified at head The derivation no longer depends on a path segment. A qualifier is now a digest of each root's canonical path ( The enumerator and the resolver derive the collision set from one code path. Both layouts you reported are now fixtures. Your nested-leaf case — root3 at You were right that the old test could not catch it. The tests also assert the withheld direction — that the resolver accepts no qualified key the catalogue never minted — because the forward direction alone passed under both mutations. |
…e skill keys Two bundles vendoring the same relative path collide on one `package/<rel>` key, which fails closed and leaves both copies unaddressable. `package/<qualifier>:<rel>` narrows the globbed candidates to the one root whose derived qualifier matches; a key without `:` resolves exactly as before.
Problem / Motivation
The skill catalog keys a package-contributed skill
package/<relative-path>. For aroot shaped
<...>/packages/<Pkg>/<event>/skills, the name that distinguishes thebundle lives in the root, so it is absent from the relative path — and two
bundles vendoring a skill at the same relative path collide on one key.
_resolve_package_skill_pathhandles that collision fail-closed on purpose: itreturns
Noneand logs rather than serving an arbitrary one of the two, because areader who opens one skill and silently gets another has no way to notice. Correct,
but the consequence is that both copies become unaddressable — the key names two
files, so it names none.
Enumeration had the mirror-image gap:
enumerate_skill_catalogkept whichever rootwas walked first (
setdefault) and dropped the other, so the surviving key was onethe resolver then refused — a phantom row in the agent-template editor, with the
other copy unreachable entirely.
Why it matters
That contradicts the invariant the two functions document about each other: a key
the catalog offers must be one the resolver accepts, and vice versa. Today it is
violated silently in both directions — a listed key that 404s, and a skill that is
installed and loadable by agents but absent from the catalog with no user-visible
signal.
The omission scales with how many bundles share a skill set, so it is not a corner
case for an install that vendors several. It was REPORTED on one such install as
well over a hundred skills missing from the catalog — that figure comes from the
report, not from this repo: the only in-repo
extra_skills()returns[](
platform/defaults.py), so no layout here produces a collision and no in-repotest can reproduce the count. What the repo CAN show is the mechanism, and the
tests pin exactly that: a synthesised two-root collision loses a row to the
enumerator's
setdefaultand the resolver's fail-closed refusal.What changed (motivation → approach → change)
An optional qualifier in the key:
package/<qualifier>:<rel>.A qualifier names the wanted copy's root by its own identity. It narrows an
already-globbed candidate set and never widens the search — the same roots are
globbed with the same patterns, so a qualifier can only turn a refusal into a hit;
it can never reach a root the unqualified request would not have searched. The
exclusion of core-owned roots (a
package/key must never answer with the user's owneditable skill) is therefore untouched, and there is a test that names that root's
segment as a qualifier and still gets
None.A qualifier is a DERIVED, identity-bound token: a
blake2bdigest of the wantedcopy's root canonical path TOGETHER WITH that root's
st_dev,st_ino,st_ctime_nsandst_mtime_ns, so a key readspackage/05c564ec5e9e4b7a8c1d2e3f4a5b6c7d:<rel>. Both sides RE-DERIVE it with the same function, soresolution is the exact inverse of enumeration; membership of a segment in a root is
explicitly NOT the contract, because a replaced root that still carries a segment would
then re-bind a held key to a different file. Deriving from the root ALONE also makes the
key stable: installing or removing an unrelated bundle cannot re-spell it. It stays mapping-free even so: the core has no root-to-package
mapping and needs none, since the identity half is taken from the root's own path rather
than from any package name, and that knowledge belongs to whichever edition installs the
roots. A qualifier carrying
/,..or a leading~is refused up front, so amalformed one can only fail closed.
Four properties worth calling out:
pre-existing code path unchanged, and
split_package_skill_keytreats a half-emptyqualifier (
:x,x:) as unqualified rather than as an empty glob pattern. A keythat DOES carry
:is the one deliberate exception — see the reservation below._package_collisionisthe only place the set is built, and enumeration and resolution both call it, so the
two cannot drift into disagreeing about which roots collide — the drift that produces
a phantom row. It folds the raw glob hits to one entry per DISTINCT resolved file via
_dedupe_entries, and builds from the EXACT relative-path tier only, never the unionwith the nested-leaf tier. The layout that makes this matter is one
_collect_skills_underdocuments as supported: a root that installs a skill bysymlinking to another root's copy. Both spellings reach the tier and collapse to the
one file, so the aliasing root does not inflate the set.
holds one match would make
package/PkgB-<identity>:shared-skillservePkgA's copy under a200 when only
PkgAbundles that path — the same silent wrong-content failure theunqualified path fails closed to avoid.
files becomes one qualified key per copy, qualified by each holding root's identity
digest. A path found in one root only — or reached through a
symlink alias — keeps its plain key.
Why
:and not-The route grammar already reserves a lone
-segment ahead of the verbs(
/api/skills/{name:.+}/-/tree), so a-segment inside a key would makepackage/Pkg/-/treeambiguous between a detail GET and a tree GET.:is a legalpath character (RFC 3986 pchar) needing no escaping.
:is reserved, and that reservation is this PR's one behaviour change to anexisting key shape. The character is legal in a POSIX directory name, so a skill
directory literally named
foo:baralready enumerated and resolved before thisgrammar existed. A key carrying the separator now has exactly one reading — the
qualified one — so such a skill is omitted from the catalog and
404s on open;_resolve_skill_roothas no verbatim fallback.Reading the key both ways and disambiguating by the installed root set is the
obvious alternative, and it is rejected here on purpose: it makes a key's MEANING a
function of which roots happen to be installed, so uninstalling a root — precisely
when a key goes stale — silently re-points an existing key at a DIFFERENT package's
skill. A single reading cannot do that; it can only stop resolving. The cost is
therefore paid openly: a colon-named skill loses the key it had, the omission is
unconditional rather than root-set dependent, and
test_a_colon_named_skill_is_omitted_and_unresolvablepins both halves. A key withoutthe separator still takes exactly the pre-existing code path.
Why the resolver had to change, not just the detail path
api_skill_detaildoes not go through_resolve_skill_root; it reads the row'sown
path. Teaching only detail would make a colliding skill openable while its/tree404s. That asymmetry is worse than the clean omission it replaces, so theresolver change is the mandatory half.
Why a root that cannot be identified is omitted rather than kept
A path SEGMENT cannot serve as the qualifier at all, which is why an identity was
chosen instead. Any segment rule is evaluated against whichever roots collide at
derivation time, so the value it picks MOVES when that set does: a segment can differ at
its own index while still occurring deeper in a sibling root (
/x/PkgA/skillsvs/x/nested/PkgA/skills), matching both candidates and making the resolver refuse a keyenumeration had just offered. A digest over the root's own canonical path and its own stat identity is a
function of that root ALONE, so widening the candidate set cannot re-spell an
already-minted key. The stat terms are why an in-place bundle replacement re-spells its
keys rather than silently rebinding one already held.
A digest exists for every root that canonicalises, so a collision is addressable
whatever the two roots' paths look like — including one being a path prefix of the
other. The omission branch survives only as a fail-closed backstop: when a root does
not canonicalise it yields no identity, so the path is omitted and logged with its
absolute path rather than listed under a key that resolves to nothing. That keeps the
enumeration invariant intact in the ONE direction it claims — every key the catalog
offers is one the resolver accepts — and is diagnosable rather than silent, and it
affects only a key that already resolved to nothing. The converse is deliberately not
claimed: resolution is WIDER than enumeration, because it also accepts a leaf-name key
through its nested tier, so a key the catalog never listed can still resolve. Read as
two-way the claim would be false; one-way it is what the test below asserts.
It is not, however, the only behaviour change to an existing key. The reserved
package/prefix is the other one, and it is deliberate and tested rather thanincidental:
api_skill_detailnow discards aload_skillhit for anypackage/-prefixedname, where before that hit won and was served.
enumerate_skill_catalogprunes every core row whose own relative path keysinto the reserved prefix, with a warning.
package/key is refused when its inode carriesmore than one link, and the endpoints answer
404(file_not_found) ratherthan
403: the descriptor gate reports nothing for an absent or unopenable filetoo, so the response cannot claim a denial it is unable to substantiate, and the
withheld read is carried on the audit trail instead. A hardlink canonicalises to its own in-root path, so
containment and the sensitive-name check both pass while the bytes belong to the
shared inode;
st_nlinkon the opened descriptor is the only signal that a secondname exists. This reaches keys that predate the qualifier, and the scope is uneven
by endpoint:
api_skill_detailalready read through this same gate before thischange, so for the detail route the refusal is INHERITED rather than new, while the
/fileroute read throughread_skill_file's plainread_textand carried no linkcheck, so there it IS new. The cost lands on hardlink-DEDUPLICATING installs —
content-addressed package stores, store optimisation,
cp -aldeploys — where alegitimate, non-colliding package skill routinely has
st_nlink > 1, and a/fileread that used to succeed answers 404 with zero colliding roots. It is intentional and unconditional for this read-only
territory; the sibling territories keep the reader they already had.
So a core-owned skill physically installed at
skills/package/<rel>wasdetail-openable before this change and is now invisible — absent from the catalog
and 404 on open. That is the price of making a
package/key mean the packageterritory and nothing else: leaving the core hit in place is what let one key name
two files, serving the core copy in the detail modal while
/treeresolved thepackaged copy, so a spec written from the modal would load a file the tree never
showed. Both halves are covered:
test_a_core_row_under_the_reserved_prefix_is_prunedfor the enumeration prune,and
test_detail_serves_the_package_copy_not_a_core_skill_of_that_literal_namefor the detail discard.
Such a file is remediable only by hand. The create endpoint used to accept a
package/name and write it to the core root, so an install can genuinely carry one,and the refusal above stays UNCONDITIONAL for it: every mutating verb on a
package/key answers
405withAllow: GET, stranded or not. The remediation surface is theprune warning, which names the file's ABSOLUTE path — once the row is dropped that log
line is the only surface saying where the file is, and the key alone is relative to a
core root the reader cannot infer — so an operator removes it directly. Letting
DELETEfall through to the core loader instead would buy an API verb for a populationonly a pre-reservation create could have produced, at the cost of a resolver-plus-row
answerability probe and a conditional
Allowcontract in permanent API semantics.Declared rider: audit helper rename and scope
_audit_unreadis now_audit_tool, and it is called on refused MUTATIONS as well as onrefused reads. The rename follows the widened scope rather than preceding it: the old name
asserted the call site was a read, and once
package/became a read-only prefix the samehelper had to record a refused
PUT/DELETE, which the old name would have mis-described atevery one of those sites. Nine call sites read the new name and none the old.
Declared rider: the unresolved-mapping warning is generic
The warning chip, its note and the count fire for ANY mapped key the catalogue does not
resolve — not only a qualified one. That is deliberate: an unresolved mapping is the same
user-facing condition whatever spelled it, and gating the surface on the qualified grammar
would have left a plain stale key silently unexplained, which is the failure this PR exists
to remove. It does mean the surface appears on installs that never collide.
Declared rider:
PACKAGE_KEY_PREFIXOne change here is not the qualifier and is called out rather than left to be found:
the
"package/"prefix is now a named constant. Three sites read it — keyenumeration and path resolution in
_shared.py, and the detail endpoint inprompts.py— and the string decides which grammar a key is read under, so arespelling at any one of them is a silent divergence. A constant applied to only
some of those sites buys nothing, so all three use it and a test asserts the literal
is spelled exactly once in the two modules that parse the key. That test, not a
comment, is what keeps a fourth site from reintroducing the drift.
Not changed, deliberately
{name:.+}already accepts embedded slashes, and:adds nonew segment.
keyvalue varies. Rows carry no bundle-namefield, and the picker derives no qualifier at all: that surface never receives one.
handlers/__init__.pyis byte-identical tomain; everycaller of the new helpers imports them from
_shareddirectly._match_package_rowneeded one gate. Its exact-key leg returned the FIRST rowwhose
keymatched, so two rows listed under one key served an arbitrary one of themunder a 200 — the collision this grammar exists to disambiguate, reached through the
one leg that was not ambiguity-checked. The leg now refuses a duplicate key exactly as
the leaf leg already refused an ambiguous leaf. A producer-emitted qualified key still
hits it directly. Its leaf fallback
compares the key remainder to a row's
name, and for a qualified key thatremainder is
<qualifier>:<rel>, which matches no leaf — so the fallback goesquiet for qualified keys. That is the wanted behaviour: a leaf match would serve
whichever package happened to be the only row with that leaf, ignoring the
qualifier that was the whole point of the key. A test pins it.
skill://URIs.skill_key_for_uriinverts throughenumerate_skill_catalog, so a URI written against a colliding skill now invertsto a key that resolves, where before it inverted to one that did not.
Read/write territory parity is only partly fixed here — two siblings are left
This PR reserves the
package/prefix on the write paths as well as the readpaths, because that is the prefix it introduces a grammar for. The same divergence
still exists for the other two prefixed territories, and this PR deliberately does
not close it:
kiro-user/andkiro-workspace/still reach the core loaders on the writepaths, and still pass the create sanitiser: both new guards test
safe_name.startswith(PACKAGE_KEY_PREFIX)only, so aPUT,DELETEor createnaming either sibling behaves exactly as it did before this PR.
still land in the core root under a key the reader is served from elsewhere.
detaillikewise still prefers a core file over the copy the tree view shows, forthose two prefixes.
The point patch is deliberate:
package/needed enforcing on both sides here becausethis PR is what makes
package/keys answerable at all, and leaving create open wouldlet the API manufacture an orphan it has no verb to remove. Extending the same
territory check to the two siblings is a behaviour change to already-shipped,
already-consumed key spaces, with its own migration question for each, so it is left as
follow-up rather than folded in behind a grammar change.
Producer side lands separately
Catalog enumeration in this repo DOES mint qualified keys —
_merge_package_walkswrites one per distinct copy whenever two installed roots bundle the same relative path,
and a test asserts the catalogue's own key set. What no edition in this repo ships is a
LAYOUT that collides, so on a default install nothing qualifies and every existing key
behaves exactly as before; that is what makes this additive rather than inert. The
producer that will create such layouts is a downstream edition vendoring this core as a
git subtree, and its change depends on this landing here first and reaching it on
the next subtree pull. It must emit the identity-digest token above, never a
path segment: keys are derived, so the reliable path is to re-enumerate and
use the key the catalogue lists.
The
kiro-user/andkiro-workspace/prefixes disclosed below carry the sameone-key-two-files hazard and are NOT fixed here; that follow-up is tracked as #8244.
A qualifier is derived, not a name any producer owns. It is a function of the
root's own canonical path, so it does NOT move when an unrelated bundle is installed or
removed, and no other root can produce it. What can still change is whether a given rel
collides at all: if a second bundle starts or stops vendoring the same relative path, a
copy moves between its plain key and a qualified one. A key persisted across that change
resolves to nothing rather than to the wrong file. Failing closed is the intended
direction (a stale key 404s; there is no verbatim fallback), so a consumer should
re-enumerate rather than treat a key as a long-lived identifier. Whether a given
producer's install paths are stable enough to persist such keys is a property of
that producer, not of this core.
Tests
test/test_resolve_skill_root_package.pygains 45 tests:to unqualified rather than to an empty glob.
_resolve_package_skill_pathand through_resolve_skill_root(the routed entrypoint
/treeand/fileuse).another's copy, each root's own qualifier resolves to its own spelling. This fails
on the filter-after-dedupe ordering because the aliasing spelling is discarded
before the qualifier is ever applied.
Noneinstead of substituting the other copy; a qualifier that matches several colliding
roots still refuses and logs; a qualifier holding a
/or..cannot match;..anywhere in a qualified key is rejected before the split.
None.plain key; an unqualifiable collision is omitted with a warning.
package/prefix: a core row keying into it is pruned from thecatalog, and detail serves the package copy rather than a core skill of that
literal name. The fold writes each qualified key unconditionally, so what is
asserted is the CALLER's precondition — that the prune has already run when the
fold is entered — rather than a re-check inside the fold that no caller can
reach. Moving the prune after the fold fails that test.
qualified key never falls back onto one, and an uninstalled root's stale key never
binds to another package's copy.
createrefuses a reservedpackage/name, and EVERYpackage/key refusesPUT/DELETEwithAllow: GET— including a STRANDED one,whose only remediation surface is the absolute path named in the prune warning.
Each behavioural test was checked in both directions — it fails on the tree without
the change it covers, for that change's own reason, and passes with it.
The enumeration invariant is asserted by resolving every enumerated key and
comparing it to the file it was enumerated for. Asserting the
package/prefixinstead would pass vacuously under any key change — a false all-clear on exactly the
property being changed.
Manual verification
N/A — unit coverage is sufficient here. The hardlink refusal above IS user-visible (a
previously-readable skill answers 404 on a hardlink-deduplicating install), and it is
covered by unit tests rather than by clicking through. The KEY change has no UI surface
(only the
keyvalue varies, and the frontend keys editability, grouping and the read-only badge off
the
sourcefield, not the key prefix), and no edition in this repo ships a collidinglayout, so no qualified key appears on a default install and there is nothing to click
through. The behaviour that matters is
key → path resolution, which is exactly what the tests exercise, including the
routed
_resolve_skill_rootpath the tree and file endpoints call.Gates run locally:
pytest(full backend, 75465 passed),isort,flake8,mypy—all green.
scripts/local-gate.pyclassifies this diff as touching BOTH surfaces plus meta(
frontend=True meta=True backend=True, its "full gate" path), which is what the~1,850 changed lines under
website/mean: the frontend gate applies to this changeand is not waived. Frontend result at this head:
eslint srcclean, the i18n runner19/19, the skills-editor suite 38/38, and the
SkillsTabcross-surface guard 20/20.The full website suite additionally fails three tests in
src/test/ChatInput.lexical.test.tsx; that failure reproduces with everywebsite/srcchange on this branch reverted to its parent commit, so it is base-siderather than introduced here. This host also carries a set of
pre-existing BACKEND suite failures unrelated to skills (git-config and real-symlink
environment issues); the failing node-id set is identical with and without this
change, and no skills, catalog, or prompts test is among them.
Screenshots
Captured by
website/scripts/capture-package-skill-key-qualifier.mjs, which drives theREAL built SPA (
website/dist) with every/api/**call stubbed, and which ASSERTS aswell as photographs: it exits non-zero unless both colliding copies list as separate
selectable options and unless a refused write actually re-enumerates the catalog.
A real collision is two bundles vendoring the SAME skill, so the two rows share a name
AND a description and nothing in the row itself tells them apart.
GET /api/skillsnowre-keys such a row onto its qualified spelling, so each colliding copy DOES reach the
picker under its own key, and the row renders a third line naming the copy's distinguishing
path so a keyboard user can tell which bundle they are picking. Where a qualified key is
already mapped into an agent's
skill://resources, the chip renders its readable halfplus the same distinguishing path the picker row showed — with no digest fallback at all, because a 32-hex qualifier names nothing a reader could act on — which is the surface that has
to say WHICH copy is mapped.
Related Issues
#8244 — filed for the one gap this PR discloses rather than closes.
That gap is the
kiro-user/andkiro-workspace/one-key-two-files hazard describedunder What changed: those two prefixed territories still reach the core loaders on
their write paths, because both new guards test
PACKAGE_KEY_PREFIXonly, so a write canstill land in a core root under a key the reader is served from elsewhere. It is left open
here deliberately — closing it means changing the core loaders, which is a different
change from this grammar fix — and #8244 carries the reproduction so it is not lost.